diff --git a/docs/command-examples/connection.md b/docs/command-examples/connection.md new file mode 100644 index 0000000..e151d74 --- /dev/null +++ b/docs/command-examples/connection.md @@ -0,0 +1,54 @@ +## Examples + +```shell +relix connection ls -o tsv +``` +``` +name kind location file +warehouse jdbc ${WAREHOUSE_URL} ~/shop/.relix/catalog/20-warehouse.relix +``` + +Every option is a flag. A secret is a `${NAME}` placeholder, whose value comes from a +profile or the environment when a query runs: + +```shell +relix connection add archive database --url='${ARCHIVE_URL}' --user='${ARCHIVE_USER}' --password='${ARCHIVE_PASSWORD}' +cat .relix/catalog/00-connection-archive.relix +``` +``` +connection archive from jdbc { password: "${ARCHIVE_PASSWORD}", url: "${ARCHIVE_URL}", user: "${ARCHIVE_USER}" }; +``` + +and a secret given as its value is refused, so it never reaches the catalog: + +```shell +relix connection add scratch database --url=jdbc:h2:mem:scratch --password=hunter2 2>&1; echo "exit $?" +``` +``` +relix: --password: a secret is written as a ${NAME} placeholder, never its value, which would stay in the catalog, your shell's history and ps; give --password='${NAME}' and set NAME with relix profiles set +exit 2 +``` + +`test` opens and closes each connection under the profile: + +```shell +relix connection test -P dev -o tsv; echo "exit $?" +``` +``` +name status problem +archive failed ${ARCHIVE_PASSWORD} has no value: set it in a profile (-P), the environment or -D +warehouse ok +exit 5 +``` + +```shell +relix connection rm archive +relix connection test -P dev -o tsv +``` +``` +name status problem +warehouse ok +``` + +[The catalog](../catalog.md#declaring-sources-and-connections) covers where declarations +go, and the trust a project keeps across them. diff --git a/docs/command-examples/init.md b/docs/command-examples/init.md new file mode 100644 index 0000000..7d3033e --- /dev/null +++ b/docs/command-examples/init.md @@ -0,0 +1,29 @@ +## Examples + +```shell +relix init ~/stall --root -P dev -o csv +find ~/stall/.relix | sort +cat ~/stall/.relix/relixrc +``` +``` +~/stall/.relix +~/stall/.relix/.gitignore +~/stall/.relix/catalog +~/stall/.relix/profiles.json +~/stall/.relix/relixrc +~/stall/.relix/root +output = csv +profile = dev +``` + +A directory that has a `.relix/` already is refused, and nothing in it changes: + +```shell +relix init ~/stall 2>&1; echo "exit $?" +``` +``` +relix: ~/stall/.relix already exists; relix init makes a new project and never changes one +exit 2 +``` + +[The catalog](../catalog.md#starting-a-project) covers what each file is for. diff --git a/docs/command-examples/profiles.md b/docs/command-examples/profiles.md new file mode 100644 index 0000000..d42c139 --- /dev/null +++ b/docs/command-examples/profiles.md @@ -0,0 +1,36 @@ +## Examples + +```shell +relix profiles ls -o tsv +``` +``` +profile variable file +dev WAREHOUSE_PASSWORD ~/.relix/profiles.json +dev WAREHOUSE_URL ~/shop/.relix/profiles.json +dev WAREHOUSE_USER ~/shop/.relix/profiles.json +``` + +The value comes from standard input, or a prompt that does not echo it on a terminal, +never from an argument: + +```shell +printf 'jdbc:h2:mem:staging\n' | relix profiles set staging WAREHOUSE_URL +relix profiles get staging WAREHOUSE_URL +``` +``` +jdbc:h2:mem:staging +``` + +```shell +relix profiles rm staging +relix profiles ls -o tsv +``` +``` +profile variable file +dev WAREHOUSE_PASSWORD ~/.relix/profiles.json +dev WAREHOUSE_URL ~/shop/.relix/profiles.json +dev WAREHOUSE_USER ~/shop/.relix/profiles.json +``` + +[Secrets and profiles](../secrets.md#setting-profile-values) covers where values come +from, and what keeps them private. diff --git a/docs/command-examples/source.md b/docs/command-examples/source.md new file mode 100644 index 0000000..9e746bb --- /dev/null +++ b/docs/command-examples/source.md @@ -0,0 +1,73 @@ +## Examples + +```shell +relix source ls -o tsv +``` +``` +name kind location file +Customers csv ../../data/customers.csv ~/shop/.relix/catalog/00-sources.relix +Orders csv ../../data/orders.csv ~/shop/.relix/catalog/00-sources.relix +Stock warehouse STOCK ~/shop/.relix/catalog/20-warehouse.relix +``` + +`add` writes one declaration to a file of its own. A relative `LOCATION` is written +relative to that file, so it still names the same file from anywhere in the project, and +`--infer-schema` reads the file for its columns: + +```shell +relix source add People csv data/customers.csv --header --infer-schema +cat .relix/catalog/00-source-People.relix +``` +``` +source People from csv("../../data/customers.csv") { header: true, schema: { customer_id: number, name: string, city: string } }; +``` + +`test` reads one row of each source under the profile, and checks the schema it declares +against what it read: + +```shell +relix source test -P dev -o tsv +``` +``` +name status problem +Customers ok +Orders ok +People ok +Stock ok +``` + +A source another declaration reads is not removed without `--force`: + +```shell +relix source rm Orders 2>&1; echo "exit $?" +``` +``` +relix: Orders is read by + Completed (~/shop/.relix/catalog/10-views.relix:1) +remove those first, or give --force +exit 2 +``` + +and one declared beside others in a file written by hand is reported, not changed: + +```shell +relix source edit Customers --header=false 2>&1; echo "exit $?" +``` +``` +relix: ~/shop/.relix/catalog/00-sources.relix:2 declares Customers beside other declarations, which relix source does not edit for you; relix source edit Customers --editor +exit 2 +``` + +```shell +relix source rm People +relix source ls -o tsv +``` +``` +name kind location file +Customers csv ../../data/customers.csv ~/shop/.relix/catalog/00-sources.relix +Orders csv ../../data/orders.csv ~/shop/.relix/catalog/00-sources.relix +Stock warehouse STOCK ~/shop/.relix/catalog/20-warehouse.relix +``` + +[The catalog](../catalog.md#declaring-sources-and-connections) covers where declarations +go, and the trust a project keeps across them. diff --git a/docs/design.md b/docs/design.md index 1e84099..4cf4e53 100644 --- a/docs/design.md +++ b/docs/design.md @@ -59,8 +59,10 @@ relix [GLOBAL OPTIONS] [RUN OPTIONS] [SCRIPT...] # `run` is the default co | `relix ir` | the IR report | `--ir` / default | | `relix provenance` | annotated rows (`--semiring`, `--weight`) | `--provenance` | | `relix fmt` | the script, printed canonically (`--glyphs`/`--keywords`) | *new* | +| `relix init [DIR]` | nothing: creates a project's `.relix/` (`--root`, `-o`, `-P`) and trusts it | *new* (§5.1) | | `relix catalog` | `ls`, `schema NAME`, `where NAME`, `files`, `trust` | *new* (§5) | -| `relix profiles` | the named environments and where each came from | `relix env` | +| `relix source`, `relix connection` | `add`, `ls`, `edit`, `rm`, `test`: declarations from flags (§5.3) | *new* | +| `relix profiles` | `ls` (profile, variable, file; never a value), `set`, `get`, `rm` | `relix env` | | `relix drivers` | `ls`, `install NAME` | `--download-drivers` | | `relix connectors` | `ls`, `install NAME` | `relix connectors`, `--download-connectors` | | `relix doc [TOPIC]` | a reference page (paged on a TTY) | `relix help lang …` | @@ -226,6 +228,12 @@ drains the query for its events and throws the rows away, because both wanted st catalog/*.relix ``` +`relix init [DIR]` creates a project's directory: `catalog/`, a `profiles.json` at mode +`0600` (holding the profile `-P` names, empty), a `.gitignore` inside `.relix/` that +holds `profiles.json`, and with `-o`/`-P` a `relixrc`, with `--root` the `root` marker. +It records the new directory as trusted (§5.4), and refuses, with exit 2, a directory +that already has a `.relix/`. + ### 5.2 Discovery 1. Walk from the working directory (or `-C DIR`) upward, collecting each `.relix/` @@ -258,6 +266,17 @@ relix catalog schema Orders # columns and types relix catalog where Orders # the declaration that won, and the ones it shadowed ``` +`relix source` and `relix connection` (`add | ls | edit | rm | test`) declare data from +flags: `add NAME KIND|CONNECTION [LOCATION]`, every other option a `--OPTION=VALUE` flag, +`--infer-schema` to read a CSV file's or a table's columns. Each `add` writes one +declaration to `catalog/00-source-.relix` or `00-connection-.relix`, as +`relix fmt` prints it; one declaration per file is what makes `edit` and `rm` safe, and a +declaration beside others is reported (file and line) rather than touched. A secret is +written as a `${…}` placeholder, never a value. `rm` refuses a name another declaration +reads unless `--force`. `test` reads one row per source (or opens each database +connection) under the profile and exits 5 when one fails. `--global` writes `~/.relix`. +A JSON source is schema-on-read and declares no schema, so it takes no `--infer-schema`. + `catalog ls` and `schema` are queries over the engine's own introspection relations (`relix.relations`, `relix.columns`), run through the same row renderers, so they come out as TSV/NDJSON in a pipe for free. @@ -275,7 +294,20 @@ a name it would have supplied. `RELIX_TRUST_ALL=1` exists for CI containers. What is trusted without asking is what the user chose: `~/.relix`, a script named on the command line, `-e`, stdin, and `--catalog` files. `relix catalog trust --list` and -`--revoke DIR` manage the record. `profiles.json` (any level) is refused, as `ssh` +`--revoke DIR` manage the record. + +A command that writes under a `.relix/` (`init`, `profiles set`/`rm`, `source` and +`connection`) would untrust the project with its own change. So before writing it checks +that the directory is trusted **and** its files still match the recorded hash; if both +hold, it records the new hash after writing and the project stays trusted. Otherwise it +stays untrusted after the write too, so a change someone else made is still reviewed +before it loads. The command says which happened, on stderr. + +`relix profiles set PROFILE VAR` writes a value to the nearest project's `profiles.json` +(or `~/.relix`'s with `--user`), creating it at `0600` and refusing a looser one. The value +comes from a prompt that does not echo on a terminal, from stdin otherwise, and never +from an argument. It warns when git tracks the file. `get` prints the value a run would +resolve; `ls` lists names only. `profiles.json` (any level) is refused, as `ssh` refuses a loose private key, when it is readable by group or others, since it may hold credentials; the error names the `chmod` that fixes it. On Windows, where POSIX permission bits do not apply, this check is skipped. diff --git a/docs/guide/command-line/README.md b/docs/guide/command-line/README.md index 32c33c6..8e94f6a 100644 --- a/docs/guide/command-line/README.md +++ b/docs/guide/command-line/README.md @@ -28,8 +28,8 @@ What a directory gives the commands run in it. | Page | What it covers | |---|---| -| [The catalog](catalog.md) | `.relix/` directories and how they are found; trusting a project; a nearer declaration wins; inspecting the catalog; `--catalog`; `relixrc` | -| [Secrets and profiles](secrets.md) | `${NAME}` placeholders; `profiles.json` and `-P`; the environment and `-D`; private profile files; what is never printed | +| [The catalog](catalog.md) | `.relix/` directories and how they are found; trusting a project; a nearer declaration wins; inspecting the catalog; `--catalog`; `relixrc`; starting a project with `relix init`; declaring sources and connections | +| [Secrets and profiles](secrets.md) | `${NAME}` placeholders; `profiles.json` and `-P`; setting values with `relix profiles`; the environment and `-D`; private profile files; what is never printed | ## Command-line recipes @@ -54,7 +54,11 @@ and examples. | [`relix ir`](commands/ir.md) | Prints the symbols, views and queries the analyser made of a script | | [`relix provenance`](commands/provenance.md) | Runs each query and annotates each row with where it came from | | [`relix fmt`](commands/fmt.md) | Prints scripts canonically, keeping their comments | +| [`relix init`](commands/init.md) | Creates a project's `.relix/` directory, and trusts it | | [`relix catalog`](commands/catalog.md) | Lists the catalog's files, relations, columns and declarations; trusts a project | +| [`relix source`](commands/source.md) | Declares, lists, changes, removes and tests the catalog's sources | +| [`relix connection`](commands/connection.md) | Declares, lists, changes, removes and tests the catalog's connections | +| [`relix profiles`](commands/profiles.md) | Lists, sets and removes the variables of the profiles | | [`relix drivers`](commands/drivers.md) | Lists and installs JDBC drivers | | [`relix connectors`](commands/connectors.md) | Lists and installs connector plugins | | [`relix doc`](commands/doc.md) | Shows a page of the language reference | diff --git a/docs/guide/command-line/catalog.md b/docs/guide/command-line/catalog.md index 1078175..84ae32a 100644 --- a/docs/guide/command-line/catalog.md +++ b/docs/guide/command-line/catalog.md @@ -326,3 +326,86 @@ Bob Carol Dan ``` + +## Starting a project + +`relix init` makes a project's `.relix/` directory, in the working directory or the one it +names: + +```shell +relix init ~/stall +find ~/stall/.relix | sort +``` +``` +~/stall/.relix +~/stall/.relix/.gitignore +~/stall/.relix/catalog +~/stall/.relix/profiles.json +``` + +It writes an empty `catalog/`, a `profiles.json` only you can read, and a `.gitignore` +inside `.relix/` that keeps `profiles.json` out of version control, so no file of yours +outside the directory is touched. `-o FORMAT` and `-P NAME` write a `relixrc` with those +defaults, and `--root` writes the `root` marker. Then it trusts the new directory, as +`relix catalog trust` would, so the first run there loads it without a warning: + +```shell +relix catalog trust --list -o tsv +``` +``` +directory state +~/shop trusted +~/shop/reports trusted +~/stall trusted +``` + +A directory that already has a `.relix/` is refused with exit status 2: `init` makes a +new project and never changes one. Started outside your home directory, it warns that +only the working directory's own `.relix/` is read there. + +## Declaring sources and connections + +[`relix source`](commands/source.md) and [`relix connection`](commands/connection.md) +declare data without a catalog file written by hand. The two mirror the language's two +declarations: a source is a relation, and a connection is what several sources share, +such as a database and its credentials. Both take `add`, `ls`, `edit`, `rm` and `test`: + +```shell +cd ~/stall +relix connection add shop database --url='${SHOP_URL}' --user='${SHOP_USER}' --password='${SHOP_PASSWORD}' +relix source add Orders csv ~/shop/data/orders.csv --infer-schema +relix source ls -o tsv +``` +``` +name kind location file +Orders csv ~/shop/data/orders.csv ~/stall/.relix/catalog/00-source-Orders.relix +``` + +Each `add` writes one declaration to a file of its own, `catalog/00-source-NAME.relix` or +`catalog/00-connection-NAME.relix`, printed as `relix fmt` prints it, and every option of +the declaration is a flag: `--OPTION=VALUE`, or `--OPTION` alone for `true`. A secret is +written as a `${NAME}` placeholder and never as its value, which the command refuses. It +writes the nearest project's catalog, or with `--global` your own `~/.relix`. A name an +outer level declares is allowed, as the overlay allows it, and warned about. + +One declaration to a file is what lets `edit` and `rm` change it safely. A declaration +written by hand beside others in its file is reported with its file and line rather than +touched; `edit NAME --editor` opens it in `$EDITOR` there. `rm` refuses a name another +declaration reads, unless `--force`. Views and `def`s have no command: they are Relix +code, and an editor is the tool for code. + +Changing a file under a trusted project would untrust it, so these commands, `init` and +[`relix profiles`](secrets.md#setting-profile-values) look first: a project that is +trusted, and unchanged since, is trusted again after the change, and any other stays +untrusted, so a change someone else made is still reviewed. `relix catalog trust --list` +shows the project still trusted: + +```shell +relix catalog trust --list -o tsv +``` +``` +directory state +~/shop trusted +~/shop/reports trusted +~/stall trusted +``` diff --git a/docs/guide/command-line/commands/connection.md b/docs/guide/command-line/commands/connection.md new file mode 100644 index 0000000..11ed610 --- /dev/null +++ b/docs/guide/command-line/commands/connection.md @@ -0,0 +1,222 @@ +# relix connection + +Declares, lists, changes, removes and tests connections: what several sources share, such as a database and its credentials. Each add writes one declaration to a file of its own, catalog/00-connection-NAME.relix, as relix fmt prints it. + +## Usage + +```text +relix connection [OPTIONS] [COMMAND] +``` + +It also takes the [global options](relix.md#global-options). + +## relix connection add + +Declares the connection NAME of KIND: database (jdbc), a connector's name such as mongodb, or a file kind (csv, log, gedcom) whose path is LOCATION. Every option of the declaration is a flag, --OPTION=VALUE, its value a string. A secret is written as a ${NAME} placeholder, never its value. A name an outer level declares is allowed, and warned about. Writes the nearest project's catalog, or with --global ~/.relix's. + +### Usage + +```text +relix connection add [OPTIONS] NAME KIND [LOCATION] +``` + +### Arguments + +| Argument | Description | +|---|---| +| `NAME` | The name to declare. | +| `KIND` | What it is from: a kind (csv, json, database, http, generator), or for a source a connection's name. | +| `LOCATION` | The file it reads, relative to the working directory; for csv and json, and a file connection's path. | + +### Options + +| Option | Description | +|---|---| +| `--global` | Declare it in ~/.relix, which every directory under your home sees, rather than the nearest project. | +| `--infer-schema` | Read a sample, and write the schema it has. | + +It also takes the [global options](relix.md#global-options). + +### Exit status + +| Status | Meaning | +|---|---| +| 0 | success | +| 2 | a required option is missing, a value is a secret, or NAME is declared here already | +| 5 | the file cannot be written | + +## relix connection ls + +Lists the connections the catalog declares, each as the declaration that won: name, kind (jdbc for a database), location (its url or path) and file. + +### Usage + +```text +relix connection ls [OPTIONS] +``` + +### Options + +| Option | Description | +|---|---| +| `-o`, `--output=FORMAT` | The rows' format: table, tsv, csv, ndjson, json or markdown (default: $RELIX_OUTPUT, else relixrc's, else table on a terminal, tsv in a pipe). | +| `--no-header` | Leave out the header row of csv and tsv. | +| `--null=STRING` | How NULL is written (default: empty in csv, tsv and markdown; NULL in a table). | +| `--color=WHEN` | Colour a table: auto, always or never (default: auto). auto colours on a terminal unless $NO_COLOR is set. | + +It also takes the [global options](relix.md#global-options). + +## relix connection edit + +Changes the connection NAME: sets each --OPTION=VALUE and removes each --unset OPTION. Only a declaration alone in its file is changed; one beside others is reported, with --editor to open it at its line. + +### Usage + +```text +relix connection edit [OPTIONS] NAME +``` + +### Arguments + +| Argument | Description | +|---|---| +| `NAME` | The declaration to change. | + +### Options + +| Option | Description | +|---|---| +| `--unset=OPTION` | Remove OPTION. Repeatable. | +| `--infer-schema` | Read a sample, and write the schema it has in place of the one there. | +| `--editor` | Open the file in $VISUAL or $EDITOR at the declaration's line instead, wherever it is. | + +It also takes the [global options](relix.md#global-options). + +### Exit status + +| Status | Meaning | +|---|---| +| 0 | success | +| 2 | no connection NAME, it shares its file with other declarations, or nothing to change | +| 5 | the file cannot be written | + +## relix connection rm + +Removes the connection NAME: the file that declares it, at the nearest level that does. Refused while a source or view reads NAME, unless --force; a declaration beside others in its file is reported, not touched. Says when a farther declaration of NAME now shows through. + +### Usage + +```text +relix connection rm [OPTIONS] NAME +``` + +### Arguments + +| Argument | Description | +|---|---| +| `NAME` | The declaration to remove. | + +### Options + +| Option | Description | +|---|---| +| `--force` | Remove it though other declarations read it. | + +It also takes the [global options](relix.md#global-options). + +### Exit status + +| Status | Meaning | +|---|---| +| 0 | success | +| 2 | no connection NAME, another declaration reads it, or it shares its file | + +## relix connection test + +Opens and closes each connection NAME (default: every one) under the active profile, and lists name, status and problem: whether every placeholder has a value, its driver is installed and the database can be reached and logged into. Only a database connection is opened; any other is listed as untested. + +### Usage + +```text +relix connection test [OPTIONS] [NAME...] +``` + +### Arguments + +| Argument | Description | +|---|---| +| `NAME...` | The declarations to test (default: all). | + +### Options + +| Option | Description | +|---|---| +| `-o`, `--output=FORMAT` | The rows' format: table, tsv, csv, ndjson, json or markdown (default: $RELIX_OUTPUT, else relixrc's, else table on a terminal, tsv in a pipe). | +| `--no-header` | Leave out the header row of csv and tsv. | +| `--null=STRING` | How NULL is written (default: empty in csv, tsv and markdown; NULL in a table). | +| `--color=WHEN` | Colour a table: auto, always or never (default: auto). auto colours on a terminal unless $NO_COLOR is set. | + +It also takes the [global options](relix.md#global-options). + +### Exit status + +| Status | Meaning | +|---|---| +| 0 | every connection passed or was untested | +| 2 | no connection NAME | +| 5 | a connection failed | + +## Examples + +```shell +relix connection ls -o tsv +``` +``` +name kind location file +warehouse jdbc ${WAREHOUSE_URL} ~/shop/.relix/catalog/20-warehouse.relix +``` + +Every option is a flag. A secret is a `${NAME}` placeholder, whose value comes from a +profile or the environment when a query runs: + +```shell +relix connection add archive database --url='${ARCHIVE_URL}' --user='${ARCHIVE_USER}' --password='${ARCHIVE_PASSWORD}' +cat .relix/catalog/00-connection-archive.relix +``` +``` +connection archive from jdbc { password: "${ARCHIVE_PASSWORD}", url: "${ARCHIVE_URL}", user: "${ARCHIVE_USER}" }; +``` + +and a secret given as its value is refused, so it never reaches the catalog: + +```shell +relix connection add scratch database --url=jdbc:h2:mem:scratch --password=hunter2 2>&1; echo "exit $?" +``` +``` +relix: --password: a secret is written as a ${NAME} placeholder, never its value, which would stay in the catalog, your shell's history and ps; give --password='${NAME}' and set NAME with relix profiles set +exit 2 +``` + +`test` opens and closes each connection under the profile: + +```shell +relix connection test -P dev -o tsv; echo "exit $?" +``` +``` +name status problem +archive failed ${ARCHIVE_PASSWORD} has no value: set it in a profile (-P), the environment or -D +warehouse ok +exit 5 +``` + +```shell +relix connection rm archive +relix connection test -P dev -o tsv +``` +``` +name status problem +warehouse ok +``` + +[The catalog](../catalog.md#declaring-sources-and-connections) covers where declarations +go, and the trust a project keeps across them. diff --git a/docs/guide/command-line/commands/init.md b/docs/guide/command-line/commands/init.md new file mode 100644 index 0000000..6d72e7b --- /dev/null +++ b/docs/guide/command-line/commands/init.md @@ -0,0 +1,62 @@ +# relix init + +Creates the .relix/ directory of DIR (default: the working directory): an empty catalog/, a profiles.json only you can read, a .gitignore that keeps profiles.json out of version control, and relixrc and root when asked for: -o and -P (the global option) write a relixrc with those defaults, and the profile -P names is defined, empty, in profiles.json. Then trusts the new directory, so the next run there loads it. Writes nothing to standard output. + +## Usage + +```text +relix init [OPTIONS] [DIR] +``` + +## Arguments + +| Argument | Description | +|---|---| +| `DIR` | The project's directory, made if it does not exist. | + +## Options + +| Option | Description | +|---|---| +| `--root` | Write the root marker: discovery stops at this project, and the directories above it are not read. | +| `-o`, `--output=FORMAT` | Write a relixrc whose default output format is FORMAT. | + +It also takes the [global options](relix.md#global-options). + +## Exit status + +| Status | Meaning | +|---|---| +| 0 | success | +| 2 | DIR already has a .relix/ directory, or an option is not valid | +| 5 | a file cannot be written | + +## Examples + +```shell +relix init ~/stall --root -P dev -o csv +find ~/stall/.relix | sort +cat ~/stall/.relix/relixrc +``` +``` +~/stall/.relix +~/stall/.relix/.gitignore +~/stall/.relix/catalog +~/stall/.relix/profiles.json +~/stall/.relix/relixrc +~/stall/.relix/root +output = csv +profile = dev +``` + +A directory that has a `.relix/` already is refused, and nothing in it changes: + +```shell +relix init ~/stall 2>&1; echo "exit $?" +``` +``` +relix: ~/stall/.relix already exists; relix init makes a new project and never changes one +exit 2 +``` + +[The catalog](../catalog.md#starting-a-project) covers what each file is for. diff --git a/docs/guide/command-line/commands/profiles.md b/docs/guide/command-line/commands/profiles.md new file mode 100644 index 0000000..92e7834 --- /dev/null +++ b/docs/guide/command-line/commands/profiles.md @@ -0,0 +1,178 @@ +# relix profiles + +Lists, sets and removes the variables of the profiles ${VAR} placeholders resolve from (-P). Without a subcommand, lists them, as ls does. A value is never taken from an argument, and never listed. + +## Usage + +```text +relix profiles [OPTIONS] [COMMAND] +``` + +## Options + +| Option | Description | +|---|---| +| `-o`, `--output=FORMAT` | The rows' format: table, tsv, csv, ndjson, json or markdown (default: $RELIX_OUTPUT, else relixrc's, else table on a terminal, tsv in a pipe). | +| `--no-header` | Leave out the header row of csv and tsv. | +| `--null=STRING` | How NULL is written (default: empty in csv, tsv and markdown; NULL in a table). | +| `--color=WHEN` | Colour a table: auto, always or never (default: auto). auto colours on a terminal unless $NO_COLOR is set. | + +It also takes the [global options](relix.md#global-options). + +## relix profiles ls + +Lists each profile's variables and the file that sets each, outermost first: profile, variable and file, never the value. A profile with no variable is listed once, with an empty variable. + +### Usage + +```text +relix profiles ls [OPTIONS] +``` + +### Options + +| Option | Description | +|---|---| +| `--user` | Use ~/.relix/profiles.json rather than the nearest project's. | +| `-o`, `--output=FORMAT` | The rows' format: table, tsv, csv, ndjson, json or markdown (default: $RELIX_OUTPUT, else relixrc's, else table on a terminal, tsv in a pipe). | +| `--no-header` | Leave out the header row of csv and tsv. | +| `--null=STRING` | How NULL is written (default: empty in csv, tsv and markdown; NULL in a table). | +| `--color=WHEN` | Colour a table: auto, always or never (default: auto). auto colours on a terminal unless $NO_COLOR is set. | + +It also takes the [global options](relix.md#global-options). + +## relix profiles set + +Sets VARIABLE in PROFILE, creating either. The value is read without echoing it when standard input is a terminal, and from standard input otherwise, less one line break at its end; it is never taken from an argument, which would leave it in shell history and ps. Writes the nearest project's profiles.json, or with --user ~/.relix/profiles.json, making it readable by you alone. + +### Usage + +```text +relix profiles set [OPTIONS] PROFILE VARIABLE +``` + +### Arguments + +| Argument | Description | +|---|---| +| `PROFILE` | The profile. | +| `VARIABLE` | The variable, as ${VARIABLE} names it. | + +### Options + +| Option | Description | +|---|---| +| `--user` | Use ~/.relix/profiles.json rather than the nearest project's. | + +It also takes the [global options](relix.md#global-options). + +### Exit status + +| Status | Meaning | +|---|---| +| 0 | success | +| 2 | no project here (give --user), a bad name, or no value | +| 5 | the file is readable by others, or cannot be written | + +## relix profiles get + +Prints the value VARIABLE has in PROFILE, as a run under -P PROFILE would resolve it from the profiles.json files (the nearest wins), or with --user from ~/.relix/profiles.json alone. The environment and -D are not consulted. + +### Usage + +```text +relix profiles get [OPTIONS] PROFILE VARIABLE +``` + +### Arguments + +| Argument | Description | +|---|---| +| `PROFILE` | The profile. | +| `VARIABLE` | The variable. | + +### Options + +| Option | Description | +|---|---| +| `--user` | Use ~/.relix/profiles.json rather than the nearest project's. | + +It also takes the [global options](relix.md#global-options). + +### Exit status + +| Status | Meaning | +|---|---| +| 0 | success | +| 2 | no such profile, or it does not set VARIABLE | +| 5 | a profiles file is readable by others | + +## relix profiles rm + +Removes VARIABLE from PROFILE, or without VARIABLE the whole profile, in the nearest project's profiles.json, or with --user in ~/.relix/profiles.json. + +### Usage + +```text +relix profiles rm [OPTIONS] PROFILE [VARIABLE] +``` + +### Arguments + +| Argument | Description | +|---|---| +| `PROFILE` | The profile. | +| `VARIABLE` | The variable. | + +### Options + +| Option | Description | +|---|---| +| `--user` | Use ~/.relix/profiles.json rather than the nearest project's. | + +It also takes the [global options](relix.md#global-options). + +### Exit status + +| Status | Meaning | +|---|---| +| 0 | success | +| 2 | no project here, or the file does not set it | +| 5 | the file is readable by others, or cannot be written | + +## Examples + +```shell +relix profiles ls -o tsv +``` +``` +profile variable file +dev WAREHOUSE_PASSWORD ~/.relix/profiles.json +dev WAREHOUSE_URL ~/shop/.relix/profiles.json +dev WAREHOUSE_USER ~/shop/.relix/profiles.json +``` + +The value comes from standard input, or a prompt that does not echo it on a terminal, +never from an argument: + +```shell +printf 'jdbc:h2:mem:staging\n' | relix profiles set staging WAREHOUSE_URL +relix profiles get staging WAREHOUSE_URL +``` +``` +jdbc:h2:mem:staging +``` + +```shell +relix profiles rm staging +relix profiles ls -o tsv +``` +``` +profile variable file +dev WAREHOUSE_PASSWORD ~/.relix/profiles.json +dev WAREHOUSE_URL ~/shop/.relix/profiles.json +dev WAREHOUSE_USER ~/shop/.relix/profiles.json +``` + +[Secrets and profiles](../secrets.md#setting-profile-values) covers where values come +from, and what keeps them private. diff --git a/docs/guide/command-line/commands/relix.md b/docs/guide/command-line/commands/relix.md index 948ac64..d8ba5ea 100644 --- a/docs/guide/command-line/commands/relix.md +++ b/docs/guide/command-line/commands/relix.md @@ -74,7 +74,11 @@ Every command takes these, before or after its name. | [`relix ir`](ir.md) | Prints the IR report: the symbols, views and queries the analyser made of each script. | | [`relix provenance`](provenance.md) | Runs each query and prints its rows, each annotated with where it came from in a provenance semiring. | | [`relix fmt`](fmt.md) | Prints scripts canonically, keeping their comments. | +| [`relix init`](init.md) | Creates the .relix/ directory of DIR (default: the working directory): an empty catalog/, a profiles.json only you can read, a .gitignore that keeps profiles.json out of version control, and relixrc and root when asked for: -o and -P (the global option) write a relixrc with those defaults, and the profile -P names is defined, empty, in profiles.json. | | [`relix catalog`](catalog.md) | Inspects the catalog: the declarations a run takes from the .relix/ directories from ~/.relix down to the working directory, and from --catalog files. | +| [`relix source`](source.md) | Declares, lists, changes, removes and tests sources: the relations the catalog reads from files, databases and APIs. | +| [`relix connection`](connection.md) | Declares, lists, changes, removes and tests connections: what several sources share, such as a database and its credentials. | +| [`relix profiles`](profiles.md) | Lists, sets and removes the variables of the profiles ${VAR} placeholders resolve from (-P). | | [`relix drivers`](drivers.md) | Lists and installs JDBC drivers. | | [`relix connectors`](connectors.md) | Lists and installs connector plugins, such as mongodb. | | [`relix doc`](doc.md) | Shows the reference page for TOPIC, found by glyph, keyword or name (σ, select, selection), through $PAGER on a terminal. | diff --git a/docs/guide/command-line/commands/source.md b/docs/guide/command-line/commands/source.md new file mode 100644 index 0000000..c7f8d12 --- /dev/null +++ b/docs/guide/command-line/commands/source.md @@ -0,0 +1,242 @@ +# relix source + +Declares, lists, changes, removes and tests sources: the relations the catalog reads from files, databases and APIs. Each add writes one declaration to a file of its own, catalog/00-source-NAME.relix, as relix fmt prints it. + +## Usage + +```text +relix source [OPTIONS] [COMMAND] +``` + +It also takes the [global options](relix.md#global-options). + +## relix source add + +Declares the source NAME, from KIND, a csv or json file at LOCATION, a database, an http API or a generator, or from a connection the catalog declares. Every other option of the declaration is a flag: --OPTION=VALUE, or --OPTION for true. true, false, a number, and a { … } or [ … ] block are written as they are; anything else is a string. A secret is written as a ${NAME} placeholder, never its value. A name an outer level declares is allowed, and warned about. Writes the nearest project's catalog, or with --global ~/.relix's. + +### Usage + +```text +relix source add [OPTIONS] NAME KIND [LOCATION] +``` + +### Arguments + +| Argument | Description | +|---|---| +| `NAME` | The name to declare. | +| `KIND` | What it is from: a kind (csv, json, database, http, generator), or for a source a connection's name. | +| `LOCATION` | The file it reads, relative to the working directory; for csv and json, and a file connection's path. | + +### Options + +| Option | Description | +|---|---| +| `--global` | Declare it in ~/.relix, which every directory under your home sees, rather than the nearest project. | +| `--infer-schema` | Read a sample, and write the schema it has. | + +It also takes the [global options](relix.md#global-options). + +### Exit status + +| Status | Meaning | +|---|---| +| 0 | success | +| 2 | a required option is missing, a value is a secret, or NAME is declared here already | +| 5 | --infer-schema cannot read the data, or the file cannot be written | + +## relix source ls + +Lists the sources the catalog declares, each as the declaration that won: name, kind (csv, json, database, http, generator or the connection's name), location (the file, table or URL it reads) and file. + +### Usage + +```text +relix source ls [OPTIONS] +``` + +### Options + +| Option | Description | +|---|---| +| `-o`, `--output=FORMAT` | The rows' format: table, tsv, csv, ndjson, json or markdown (default: $RELIX_OUTPUT, else relixrc's, else table on a terminal, tsv in a pipe). | +| `--no-header` | Leave out the header row of csv and tsv. | +| `--null=STRING` | How NULL is written (default: empty in csv, tsv and markdown; NULL in a table). | +| `--color=WHEN` | Colour a table: auto, always or never (default: auto). auto colours on a terminal unless $NO_COLOR is set. | + +It also takes the [global options](relix.md#global-options). + +## relix source edit + +Changes the source NAME: sets each --OPTION=VALUE, removes each --unset OPTION, and with --location reads another file. Only a declaration alone in its file is changed; one beside others is reported, with --editor to open it at its line. + +### Usage + +```text +relix source edit [OPTIONS] NAME +``` + +### Arguments + +| Argument | Description | +|---|---| +| `NAME` | The declaration to change. | + +### Options + +| Option | Description | +|---|---| +| `--unset=OPTION` | Remove OPTION. Repeatable. | +| `--infer-schema` | Read a sample, and write the schema it has in place of the one there. | +| `--editor` | Open the file in $VISUAL or $EDITOR at the declaration's line instead, wherever it is. | +| `--location=LOCATION` | Read the file at LOCATION, relative to the working directory. | + +It also takes the [global options](relix.md#global-options). + +### Exit status + +| Status | Meaning | +|---|---| +| 0 | success | +| 2 | no source NAME, it shares its file with other declarations, or nothing to change | +| 5 | --infer-schema cannot read the data, or the file cannot be written | + +## relix source rm + +Removes the source NAME: the file that declares it, at the nearest level that does. Refused while another declaration reads NAME, unless --force; a declaration beside others in its file is reported, not touched. Says when a farther declaration of NAME now shows through. + +### Usage + +```text +relix source rm [OPTIONS] NAME +``` + +### Arguments + +| Argument | Description | +|---|---| +| `NAME` | The declaration to remove. | + +### Options + +| Option | Description | +|---|---| +| `--force` | Remove it though other declarations read it. | + +It also takes the [global options](relix.md#global-options). + +### Exit status + +| Status | Meaning | +|---|---| +| 0 | success | +| 2 | no source NAME, another declaration reads it, or it shares its file | + +## relix source test + +Reads one row of each source NAME (default: every one) under the active profile, and lists name, status and problem: whether its driver or connector is installed, every placeholder has a value, its location can be reached, and its declared schema matches what was read. + +### Usage + +```text +relix source test [OPTIONS] [NAME...] +``` + +### Arguments + +| Argument | Description | +|---|---| +| `NAME...` | The declarations to test (default: all). | + +### Options + +| Option | Description | +|---|---| +| `-o`, `--output=FORMAT` | The rows' format: table, tsv, csv, ndjson, json or markdown (default: $RELIX_OUTPUT, else relixrc's, else table on a terminal, tsv in a pipe). | +| `--no-header` | Leave out the header row of csv and tsv. | +| `--null=STRING` | How NULL is written (default: empty in csv, tsv and markdown; NULL in a table). | +| `--color=WHEN` | Colour a table: auto, always or never (default: auto). auto colours on a terminal unless $NO_COLOR is set. | + +It also takes the [global options](relix.md#global-options). + +### Exit status + +| Status | Meaning | +|---|---| +| 0 | every source passed | +| 2 | no source NAME | +| 5 | a source failed | + +## Examples + +```shell +relix source ls -o tsv +``` +``` +name kind location file +Customers csv ../../data/customers.csv ~/shop/.relix/catalog/00-sources.relix +Orders csv ../../data/orders.csv ~/shop/.relix/catalog/00-sources.relix +Stock warehouse STOCK ~/shop/.relix/catalog/20-warehouse.relix +``` + +`add` writes one declaration to a file of its own. A relative `LOCATION` is written +relative to that file, so it still names the same file from anywhere in the project, and +`--infer-schema` reads the file for its columns: + +```shell +relix source add People csv data/customers.csv --header --infer-schema +cat .relix/catalog/00-source-People.relix +``` +``` +source People from csv("../../data/customers.csv") { header: true, schema: { customer_id: number, name: string, city: string } }; +``` + +`test` reads one row of each source under the profile, and checks the schema it declares +against what it read: + +```shell +relix source test -P dev -o tsv +``` +``` +name status problem +Customers ok +Orders ok +People ok +Stock ok +``` + +A source another declaration reads is not removed without `--force`: + +```shell +relix source rm Orders 2>&1; echo "exit $?" +``` +``` +relix: Orders is read by + Completed (~/shop/.relix/catalog/10-views.relix:1) +remove those first, or give --force +exit 2 +``` + +and one declared beside others in a file written by hand is reported, not changed: + +```shell +relix source edit Customers --header=false 2>&1; echo "exit $?" +``` +``` +relix: ~/shop/.relix/catalog/00-sources.relix:2 declares Customers beside other declarations, which relix source does not edit for you; relix source edit Customers --editor +exit 2 +``` + +```shell +relix source rm People +relix source ls -o tsv +``` +``` +name kind location file +Customers csv ../../data/customers.csv ~/shop/.relix/catalog/00-sources.relix +Orders csv ../../data/orders.csv ~/shop/.relix/catalog/00-sources.relix +Stock warehouse STOCK ~/shop/.relix/catalog/20-warehouse.relix +``` + +[The catalog](../catalog.md#declaring-sources-and-connections) covers where declarations +go, and the trust a project keeps across them. diff --git a/docs/guide/command-line/getting-started.md b/docs/guide/command-line/getting-started.md index 5cdf4ec..ec838b0 100644 --- a/docs/guide/command-line/getting-started.md +++ b/docs/guide/command-line/getting-started.md @@ -181,7 +181,10 @@ of its own, and each writes one thing to standard output: | [`relix ir`](commands/ir.md) | the symbols, views and queries the analyser made of the script | | [`relix provenance`](commands/provenance.md) | the rows, each with where it came from | | [`relix fmt`](commands/fmt.md) | the script, printed canonically | +| [`relix init`](commands/init.md) | nothing; it creates a project's `.relix/` directory | | [`relix catalog`](commands/catalog.md) | the catalog's files, relations, columns and trust | +| [`relix source`](commands/source.md), [`relix connection`](commands/connection.md) | the sources and connections, and declaring, changing, removing and testing them | +| [`relix profiles`](commands/profiles.md) | the profiles' variables, and setting them; a value only with `get` | | [`relix drivers`](commands/drivers.md), [`relix connectors`](commands/connectors.md) | the JDBC drivers and connector plugins, and installing them | | [`relix doc`](commands/doc.md) | a page of the language reference | | [`relix completion`](commands/completion.md) | a completion script for a shell | diff --git a/docs/guide/command-line/index.json b/docs/guide/command-line/index.json index 5f82316..c3b8b2d 100644 --- a/docs/guide/command-line/index.json +++ b/docs/guide/command-line/index.json @@ -4,8 +4,8 @@ {"path": "getting-started.md", "chapter": "The command line", "title": "The relix command", "summary": "Installing; a first query; where the script comes from; the commands; diagnostics and exit status"}, {"path": "inputs.md", "chapter": "The command line", "title": "Binding inputs", "summary": "`-i` and its formats; standard input; inferred and given headings; inputs that never end; shadowing the catalog; query parameters with `-a`"}, {"path": "output.md", "chapter": "The command line", "title": "Output formats", "summary": "`table`, `tsv`, `csv`, `ndjson`, `json` and `markdown`; NULLs and headers; scripts with several queries; `--fail-empty` and `--fail-rows`; messages; closed pipes and interrupts"}, - {"path": "catalog.md", "chapter": "Projects on the command line", "title": "The catalog", "summary": "`.relix/` directories and how they are found; trusting a project; a nearer declaration wins; inspecting the catalog; `--catalog`; `relixrc`"}, - {"path": "secrets.md", "chapter": "Projects on the command line", "title": "Secrets and profiles", "summary": "`${NAME}` placeholders; `profiles.json` and `-P`; the environment and `-D`; private profile files; what is never printed"}, + {"path": "catalog.md", "chapter": "Projects on the command line", "title": "The catalog", "summary": "`.relix/` directories and how they are found; trusting a project; a nearer declaration wins; inspecting the catalog; `--catalog`; `relixrc`; starting a project with `relix init`; declaring sources and connections"}, + {"path": "secrets.md", "chapter": "Projects on the command line", "title": "Secrets and profiles", "summary": "`${NAME}` placeholders; `profiles.json` and `-P`; setting values with `relix profiles`; the environment and `-D`; private profile files; what is never printed"}, {"path": "pipelines.md", "chapter": "Command-line recipes", "title": "Pipelines", "summary": "With `jq`, `awk`, `sort` and `head`; another program's output as a table; one `relix` into another; `tee`; with `find`, `xargs` and shell loops; scripts in here-documents; data checks in CI, comparing snapshots, and a GitHub Actions workflow; optimising a script and reviewing the change"}, {"path": "commands/relix.md", "chapter": "Command reference", "title": "relix", "summary": "The global options every command takes; the exit statuses; the environment variables and files", "command": "relix"}, {"path": "commands/run.md", "chapter": "Command reference", "title": "relix run", "summary": "Runs scripts and writes their queries' rows", "command": "relix run"}, @@ -17,7 +17,11 @@ {"path": "commands/ir.md", "chapter": "Command reference", "title": "relix ir", "summary": "Prints the symbols, views and queries the analyser made of a script", "command": "relix ir"}, {"path": "commands/provenance.md", "chapter": "Command reference", "title": "relix provenance", "summary": "Runs each query and annotates each row with where it came from", "command": "relix provenance"}, {"path": "commands/fmt.md", "chapter": "Command reference", "title": "relix fmt", "summary": "Prints scripts canonically, keeping their comments", "command": "relix fmt"}, + {"path": "commands/init.md", "chapter": "Command reference", "title": "relix init", "summary": "Creates a project's `.relix/` directory, and trusts it", "command": "relix init"}, {"path": "commands/catalog.md", "chapter": "Command reference", "title": "relix catalog", "summary": "Lists the catalog's files, relations, columns and declarations; trusts a project", "command": "relix catalog", "subcommands": [{"command": "relix catalog files", "anchor": "relix-catalog-files"}, {"command": "relix catalog ls", "anchor": "relix-catalog-ls"}, {"command": "relix catalog schema", "anchor": "relix-catalog-schema"}, {"command": "relix catalog where", "anchor": "relix-catalog-where"}, {"command": "relix catalog trust", "anchor": "relix-catalog-trust"}]}, + {"path": "commands/source.md", "chapter": "Command reference", "title": "relix source", "summary": "Declares, lists, changes, removes and tests the catalog's sources", "command": "relix source", "subcommands": [{"command": "relix source add", "anchor": "relix-source-add"}, {"command": "relix source ls", "anchor": "relix-source-ls"}, {"command": "relix source edit", "anchor": "relix-source-edit"}, {"command": "relix source rm", "anchor": "relix-source-rm"}, {"command": "relix source test", "anchor": "relix-source-test"}]}, + {"path": "commands/connection.md", "chapter": "Command reference", "title": "relix connection", "summary": "Declares, lists, changes, removes and tests the catalog's connections", "command": "relix connection", "subcommands": [{"command": "relix connection add", "anchor": "relix-connection-add"}, {"command": "relix connection ls", "anchor": "relix-connection-ls"}, {"command": "relix connection edit", "anchor": "relix-connection-edit"}, {"command": "relix connection rm", "anchor": "relix-connection-rm"}, {"command": "relix connection test", "anchor": "relix-connection-test"}]}, + {"path": "commands/profiles.md", "chapter": "Command reference", "title": "relix profiles", "summary": "Lists, sets and removes the variables of the profiles", "command": "relix profiles", "subcommands": [{"command": "relix profiles ls", "anchor": "relix-profiles-ls"}, {"command": "relix profiles set", "anchor": "relix-profiles-set"}, {"command": "relix profiles get", "anchor": "relix-profiles-get"}, {"command": "relix profiles rm", "anchor": "relix-profiles-rm"}]}, {"path": "commands/drivers.md", "chapter": "Command reference", "title": "relix drivers", "summary": "Lists and installs JDBC drivers", "command": "relix drivers", "subcommands": [{"command": "relix drivers ls", "anchor": "relix-drivers-ls"}, {"command": "relix drivers install", "anchor": "relix-drivers-install"}]}, {"path": "commands/connectors.md", "chapter": "Command reference", "title": "relix connectors", "summary": "Lists and installs connector plugins", "command": "relix connectors", "subcommands": [{"command": "relix connectors ls", "anchor": "relix-connectors-ls"}, {"command": "relix connectors install", "anchor": "relix-connectors-install"}]}, {"path": "commands/doc.md", "chapter": "Command reference", "title": "relix doc", "summary": "Shows a page of the language reference", "command": "relix doc"}, diff --git a/docs/guide/command-line/secrets.md b/docs/guide/command-line/secrets.md index 2e2d57c..18ddff0 100644 --- a/docs/guide/command-line/secrets.md +++ b/docs/guide/command-line/secrets.md @@ -116,6 +116,69 @@ A project's `profiles.json` is part of its `.relix/` directory, so it is read on the project is [trusted](catalog.md#a-project-must-be-trusted); `~/.relix/profiles.json` is always read. +### Setting profile values + +`relix profiles` lists every profile's variables and the file that sets each, but never a +value: + +```shell +relix profiles ls -o tsv +``` +``` +profile variable file +dev WAREHOUSE_PASSWORD ~/.relix/profiles.json +dev WAREHOUSE_URL ~/shop/.relix/profiles.json +dev WAREHOUSE_USER ~/shop/.relix/profiles.json +``` + +`relix profiles set PROFILE VARIABLE` sets one, so that a credential never has to be typed +into the file. The value is never an argument, which a shell would keep in its history +and `ps` would show: on a terminal it is asked for without being echoed, and otherwise it +is read from standard input, which is how a password manager hands it over +(`op read op://shop/warehouse/password | relix profiles set …`). It goes to the nearest +project's `profiles.json`, or with `--user` to your own, which is written readable by you +alone: + +```shell +printf 's3cret\n' | relix profiles set --user staging WAREHOUSE_PASSWORD +relix profiles get staging WAREHOUSE_PASSWORD +``` +``` +s3cret +``` + +`get` prints the value a run under that profile would resolve, nearest file first, since +a pipeline may need it; `--user` reads your own file alone. `relix profiles rm PROFILE +VARIABLE` removes a variable, and without `VARIABLE` the whole profile: + +```shell +relix profiles rm --user staging +relix profiles ls --user -o tsv +``` +``` +profile variable file +dev WAREHOUSE_PASSWORD ~/.relix/profiles.json +``` + +Changing a file under a trusted project would untrust it, as any change does, so `set` +and `rm` look first: when the project is trusted and its files are as they were trusted, +it is trusted again as it is after the change. A project that was not trusted, or that +someone else changed since it was, stays untrusted, so what they changed is still +reviewed before it loads. Which of the two happened is said on standard error. + +```shell +printf 'shop\n' | relix profiles set dev WAREHOUSE_USER +relix catalog trust --list -o tsv +``` +``` +directory state +~/shop trusted +~/shop/reports trusted +``` + +`set` and `rm` warn when `git` tracks the `profiles.json` they write. A project made by +[`relix init`](catalog.md#starting-a-project) ignores it from the start. + ### A profile file must be private A profile may hold a password, so a `profiles.json` that its group or others can read is diff --git a/src/main/java/com/darkcollective/relix/cli/catalog/Declaration.java b/src/main/java/com/darkcollective/relix/cli/catalog/Declaration.java index c3ade0f..76c7323 100644 --- a/src/main/java/com/darkcollective/relix/cli/catalog/Declaration.java +++ b/src/main/java/com/darkcollective/relix/cli/catalog/Declaration.java @@ -72,6 +72,17 @@ public enum Kind { this.space = space; } + /** + * Whether a declaration of this kind and one of {@code other} compete for a name, + * so that the nearer replaces the farther. + * + * @param other another kind + * @return {@code true} when they share a space of names + */ + public boolean sameSpace(Kind other) { + return space == other.space; + } + /** * The kind as {@code relix catalog} prints it. * diff --git a/src/main/java/com/darkcollective/relix/cli/catalog/Trust.java b/src/main/java/com/darkcollective/relix/cli/catalog/Trust.java index 4f168c0..3519a9d 100644 --- a/src/main/java/com/darkcollective/relix/cli/catalog/Trust.java +++ b/src/main/java/com/darkcollective/relix/cli/catalog/Trust.java @@ -168,6 +168,45 @@ public void trust(Path level) { save(); } + /** What became of a directory's trust when a command changed a file in it. */ + public enum Kept { + /** It is the user's own {@code ~/.relix}, which needs no trust. */ + OWN, + /** It was trusted and unchanged before, and is trusted as it is now. */ + TRUSTED, + /** It was untrusted, or changed by someone else since it was trusted, and still is. */ + UNTRUSTED + } + + /** + * Changes files in a {@code .relix/} directory without changing whether it is trusted. + * + *

Any change under a trusted directory untrusts it, so without this every edit a + * command makes on the user's behalf would need a {@code relix catalog trust} after + * it. When the directory is trusted and its files are still as they were when + * it was, the change is the only one, and the directory is trusted again as it now is. + * Otherwise it stays untrusted after the change too, so that a change someone else + * made is still reviewed before it loads. + * + * @param level a {@code .relix/} directory, absolute + * @param change what writes its files + * @return what became of its trust + * @throws CommandFailure with {@link ExitCode#ENVIRONMENT} when the record cannot be written + */ + public Kept keep(Path level, Runnable change) { + if (level.equals(user)) { + change.run(); + return Kept.OWN; + } + boolean trustedBefore = state(level) == State.TRUSTED; + change.run(); + if (!trustedBefore) { + return Kept.UNTRUSTED; + } + trust(level); + return Kept.TRUSTED; + } + /** * Removes a directory from the record. * diff --git a/src/main/java/com/darkcollective/relix/cli/command/ConnectionCommand.java b/src/main/java/com/darkcollective/relix/cli/command/ConnectionCommand.java new file mode 100644 index 0000000..64df968 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/ConnectionCommand.java @@ -0,0 +1,192 @@ +/* + * Copyright 2026 Darkcollective, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.darkcollective.relix.cli.command; + +import com.darkcollective.relix.cli.CommandFailure; +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.command.DeclarationSpec.Noun; +import picocli.CommandLine.Command; +import picocli.CommandLine.ParentCommand; + +import java.util.concurrent.Callable; + +/** + * {@code relix connection}: declares, lists, changes, removes and tests the catalog's + * connections, what several sources share (a database, an authenticated API), from flags + * rather than a catalog file written by hand. + */ +@Command(name = "connection", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Declares, lists, changes, removes and tests connections: what several sources", + "share, such as a database and its credentials. Each add writes one declaration", + "to a file of its own, catalog/00-connection-NAME.relix, as relix fmt prints it."}, + subcommands = { + ConnectionCommand.Add.class, ConnectionCommand.Ls.class, ConnectionCommand.Edit.class, + ConnectionCommand.Rm.class, ConnectionCommand.Test.class}) +final class ConnectionCommand implements Callable { + + @ParentCommand + RelixCommand root; + + @Override + public Integer call() { + throw new CommandFailure(ExitCode.USAGE, null); + } + + /** {@code relix connection add}. */ + @Command(name = "add", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Declares the connection NAME of KIND: database (jdbc), a connector's name such as", + "mongodb, or a file kind (csv, log, gedcom) whose path is LOCATION. Every option of", + "the declaration is a flag, --OPTION=VALUE, its value a string. A secret is", + "written as a $${NAME} placeholder, never its value. A name an outer level", + "declares is allowed, and warned about. Writes the nearest project's catalog,", + "or with --global ~/.relix's."}, + exitCodeListHeading = "%nExit status:%n", + exitCodeList = { + " 0:success", + " 2:a required option is missing, a value is a secret, or NAME is declared here already", + " 5:the file cannot be written"}) + static final class Add extends DeclarationVerbs.Add { + @ParentCommand + ConnectionCommand connection; + + @Override + RelixCommand root() { + return connection.root; + } + + @Override + Noun noun() { + return Noun.CONNECTION; + } + } + + /** {@code relix connection ls}. */ + @Command(name = "ls", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Lists the connections the catalog declares, each as the declaration that won:", + "name, kind (jdbc for a database), location (its url or path) and file."}) + static final class Ls extends DeclarationVerbs.Ls { + @ParentCommand + ConnectionCommand connection; + + @Override + RelixCommand root() { + return connection.root; + } + + @Override + Noun noun() { + return Noun.CONNECTION; + } + } + + /** {@code relix connection edit}. */ + @Command(name = "edit", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Changes the connection NAME: sets each --OPTION=VALUE and removes each --unset", + "OPTION. Only a declaration alone in its file is changed; one beside others is", + "reported, with --editor to open it at its line."}, + exitCodeListHeading = "%nExit status:%n", + exitCodeList = { + " 0:success", + " 2:no connection NAME, it shares its file with other declarations, or nothing to change", + " 5:the file cannot be written"}) + static final class Edit extends DeclarationVerbs.Edit { + @ParentCommand + ConnectionCommand connection; + + @Override + RelixCommand root() { + return connection.root; + } + + @Override + Noun noun() { + return Noun.CONNECTION; + } + } + + /** {@code relix connection rm}. */ + @Command(name = "rm", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Removes the connection NAME: the file that declares it, at the nearest level that", + "does. Refused while a source or view reads NAME, unless --force; a", + "declaration beside others in its file is reported, not touched. Says when a", + "farther declaration of NAME now shows through."}, + exitCodeListHeading = "%nExit status:%n", + exitCodeList = { + " 0:success", + " 2:no connection NAME, another declaration reads it, or it shares its file"}) + static final class Rm extends DeclarationVerbs.Rm { + @ParentCommand + ConnectionCommand connection; + + @Override + RelixCommand root() { + return connection.root; + } + + @Override + Noun noun() { + return Noun.CONNECTION; + } + } + + /** {@code relix connection test}. */ + @Command(name = "test", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Opens and closes each connection NAME (default: every one) under the active", + "profile, and lists name, status and problem: whether every placeholder has a", + "value, its driver is installed and the database can be reached and logged into.", + "Only a database connection is opened; any other is listed as untested."}, + exitCodeListHeading = "%nExit status:%n", + exitCodeList = {" 0:every connection passed or was untested", " 2:no connection NAME", + " 5:a connection failed"}) + static final class Test extends DeclarationVerbs.Test { + @ParentCommand + ConnectionCommand connection; + + @Override + RelixCommand root() { + return connection.root; + } + + @Override + Noun noun() { + return Noun.CONNECTION; + } + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/DeclarationSpec.java b/src/main/java/com/darkcollective/relix/cli/command/DeclarationSpec.java new file mode 100644 index 0000000..d6c4d5c --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/DeclarationSpec.java @@ -0,0 +1,329 @@ +/* + * Copyright 2026 Darkcollective, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.darkcollective.relix.cli.command; + +import com.darkcollective.relix.cli.CommandFailure; +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.embed.Relix; +import com.darkcollective.relix.lang.ast.ScriptParseException; +import com.darkcollective.relix.lang.ast.ScriptPrinter; +import com.darkcollective.relix.lang.ast.Statement; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * One {@code source} or {@code connection} declaration as {@code relix source} and + * {@code relix connection} build it from flags and change it: its name, what it is + * {@code from}, a location for a file kind, and its options, each held as the Relix text + * of its value. + * + *

The text it makes is parsed by the engine before it is written anywhere, so the + * engine, not this class, decides what a declaration needs: a missing option is the + * parser's "missing 'url'", reported as the flag {@code --url}. + */ +final class DeclarationSpec { + + /** The source kinds that read one file, named in parentheses: {@code csv("…")}. */ + static final Set FILE_KINDS = Set.of("csv", "json"); + + /** The source kinds the language has besides a connection's name. */ + static final Set SOURCE_KINDS = Set.of("csv", "json", "database", "http", "generator"); + + private static final Pattern NAME = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + private static final Pattern NUMBER = Pattern.compile("-?[0-9]+(\\.[0-9]+)?"); + private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{[A-Za-z_][A-Za-z0-9_]*}"); + private static final Pattern SECRET_KEY = + Pattern.compile("(?i).*(password|passwd|secret|token|api_?key|credential).*"); + private static final Pattern MISSING = Pattern.compile("missing '([A-Za-z_]+)'"); + private static final Pattern PRINTED = Pattern.compile( + "(source|connection)\\s+([A-Za-z_][A-Za-z0-9_]*)\\s+from\\s+(.*?)\\s*\\{(.*)}\\s*;\\s*", Pattern.DOTALL); + private static final Pattern FILE_HEAD = Pattern.compile("([A-Za-z_]+)\\(\\s*(\".*\")\\s*\\)", Pattern.DOTALL); + + /** What is declared. */ + enum Noun { + SOURCE, CONNECTION; + + /** The keyword, as a command and a file name write it. */ + String word() { + return name().toLowerCase(Locale.ROOT); + } + } + + private final Noun noun; + private final String name; + private String from; + private String location; + private final LinkedHashMap options; + + private DeclarationSpec(Noun noun, String name, String from, String location, Map options) { + this.noun = noun; + this.name = name; + this.from = from; + this.location = location; + this.options = new LinkedHashMap<>(options); + } + + /** + * A new declaration. + * + * @param noun what it declares + * @param name its name + * @param from its kind, or for a source the connection it reads + * @return the declaration, with no location and no options + * @throws CommandFailure with {@link ExitCode#USAGE} when a name is not a plain name + */ + static DeclarationSpec of(Noun noun, String name, String from) { + requireName(noun.word() + " name", name); + requireName("KIND", from); + return new DeclarationSpec(noun, name, noun == Noun.CONNECTION ? from.toLowerCase(Locale.ROOT) : from, + null, Map.of()); + } + + /** + * The declaration a parsed statement makes, read from the engine's canonical printing + * of it. + * + * @param statement a {@code source} or {@code connection} declaration + * @return it, as a spec to change + * @throws IllegalArgumentException when the printing is not a declaration of that shape + */ + static DeclarationSpec read(Statement statement) { + String printed = ScriptPrinter.print(statement); + Matcher m = PRINTED.matcher(printed.strip()); + if (!m.matches()) { + throw new IllegalArgumentException("not a source or connection declaration: " + printed); + } + Noun noun = m.group(1).equals("source") ? Noun.SOURCE : Noun.CONNECTION; + String head = m.group(3).strip(); + String from = head; + String location = null; + Matcher file = FILE_HEAD.matcher(head); + if (file.matches()) { + from = file.group(1); + location = unquote(file.group(2)); + } + Map options = new LinkedHashMap<>(); + for (String entry : splitTopLevel(m.group(4), ',')) { + if (entry.isBlank()) { + continue; + } + int colon = topLevelIndex(entry, ':'); + options.put(entry.substring(0, colon).strip(), entry.substring(colon + 1).strip()); + } + return new DeclarationSpec(noun, m.group(2), from, location, options); + } + + /** An independent copy, to change without changing this. */ + DeclarationSpec copy() { + return new DeclarationSpec(noun, name, from, location, options); + } + + Noun noun() { + return noun; + } + + String name() { + return name; + } + + /** Its kind, or the connection a source reads. */ + String from() { + return from; + } + + /** Whether it is a source of one of the file kinds. */ + boolean isFile() { + return noun == Noun.SOURCE && FILE_KINDS.contains(from); + } + + /** The file a file source reads, as written. */ + Optional location() { + return Optional.ofNullable(location); + } + + void location(String location) { + this.location = location; + } + + /** Each option's value, as Relix text. */ + Map options() { + return options; + } + + /** An option's value, unquoted when it is a string. */ + Optional option(String key) { + String value = options.get(key); + return value == null ? Optional.empty() : Optional.of(value.startsWith("\"") ? unquote(value) : value); + } + + /** + * Sets an option from a flag's value: for a source, {@code true}, {@code false}, a + * number, or a {@code {…}} or {@code […]} block as written, and anything else as a + * string; for a connection, always a string, as its properties are. + * + * @param key the option, as the flag names it + * @param value the flag's value + * @throws CommandFailure with {@link ExitCode#USAGE} when the key is not a plain name, + * or a secret is given as a value rather than a placeholder + */ + void set(String key, String value) { + requireName("--" + key, key); + requirePlaceholderSecret(key, value); + boolean raw = noun == Noun.SOURCE && (value.equals("true") || value.equals("false") + || NUMBER.matcher(value).matches() || value.startsWith("{") || value.startsWith("[")); + options.put(key, raw ? value : quote(value)); + } + + /** Sets an option to Relix text as it stands. */ + void setText(String key, String text) { + options.put(key, text); + } + + /** Removes an option, saying whether it was there. */ + boolean unset(String key) { + return options.remove(key) != null; + } + + /** + * The declaration's text: one statement, as the parser reads it. + * + * @return the text + */ + String text() { + StringBuilder text = new StringBuilder(noun.word()).append(' ').append(name).append(" from ").append(from); + if (location != null) { + text.append('(').append(quote(location)).append(')'); + } + text.append(" {"); + String separator = " "; + for (Map.Entry option : options.entrySet()) { + text.append(separator).append(option.getKey()).append(": ").append(option.getValue()); + separator = ", "; + } + return text.append(options.isEmpty() ? "};" : " };").toString(); + } + + /** + * Parses the declaration as it would be read from {@code file}. + * + * @param file the path to parse it under, so a relative path resolves against its directory + * @return the statement + * @throws CommandFailure with {@link ExitCode#USAGE} when it does not parse, naming the + * flag a missing option is given with + */ + Statement parse(String file) { + try { + List statements = Relix.parse(text(), file).statements(); + return statements.getFirst(); + } catch (ScriptParseException e) { + Matcher missing = MISSING.matcher(e.getMessage()); + if (missing.find()) { + String flag = "--" + missing.group(1); + throw new CommandFailure(ExitCode.USAGE, flag + " is required: " + e.getMessage() + + (missing.group(1).equals("schema") ? " (give --schema='{ … }', or --infer-schema)" : "")); + } + throw new CommandFailure(ExitCode.USAGE, "the declaration does not parse: " + e.getMessage() + + "\n " + text()); + } + } + + /** + * Refuses a secret given as its value: an option whose name says it is a credential, + * or a URL carrying a password, must be a {@code ${NAME}} placeholder, so that the + * value stays out of the catalog, the shell's history and {@code ps}. + */ + private static void requirePlaceholderSecret(String key, String value) { + boolean secretKey = SECRET_KEY.matcher(key).matches(); + boolean passwordInUrl = value.toLowerCase(Locale.ROOT).matches(".*[?&;:]password=.*"); + if ((secretKey && !PLACEHOLDER.matcher(value).matches()) + || (passwordInUrl && !value.matches("(?i).*password=\\$\\{[A-Za-z_][A-Za-z0-9_]*}.*"))) { + throw new CommandFailure(ExitCode.USAGE, "--" + key + ": a secret is written as a ${NAME}" + + " placeholder, never its value, which would stay in the catalog, your shell's history" + + " and ps; give --" + key + "='${NAME}' and set NAME with relix profiles set"); + } + } + + static void requireName(String what, String name) { + if (!NAME.matcher(name).matches()) { + throw new CommandFailure(ExitCode.USAGE, what + ": '" + name + + "' is not a plain name: letters, digits and _, not starting with a digit"); + } + } + + /** A value as a Relix string literal, as the engine's printer writes one. */ + static String quote(String value) { + return '"' + value.replace("\\", "\\\\").replace("\"", "\\\"") + '"'; + } + + /** A Relix string literal's value. */ + static String unquote(String literal) { + String body = literal.substring(1, literal.length() - 1); + StringBuilder value = new StringBuilder(body.length()); + for (int i = 0; i < body.length(); i++) { + char c = body.charAt(i); + if (c == '\\' && i + 1 < body.length()) { + c = body.charAt(++i); + } + value.append(c); + } + return value.toString(); + } + + /** {@code text} split at each {@code separator} outside a string, braces, brackets or parentheses. */ + private static List splitTopLevel(String text, char separator) { + List parts = new ArrayList<>(); + int depth = 0; + char quote = 0; + int start = 0; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (quote != 0) { + if (c == '\\' && quote == '"') { + i++; + } else if (c == quote) { + quote = 0; + } + } else if (c == '"' || c == '`') { + quote = c; + } else if (c == '{' || c == '[' || c == '(') { + depth++; + } else if (c == '}' || c == ']' || c == ')') { + depth--; + } else if (c == separator && depth == 0) { + parts.add(text.substring(start, i)); + start = i + 1; + } + } + parts.add(text.substring(start)); + return parts; + } + + private static int topLevelIndex(String text, char c) { + int index = splitTopLevel(text, c).getFirst().length(); + if (index >= text.length()) { + throw new IllegalArgumentException("no '" + c + "' in: " + text); + } + return index; + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/DeclarationVerbs.java b/src/main/java/com/darkcollective/relix/cli/command/DeclarationVerbs.java new file mode 100644 index 0000000..86ae8bb --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/DeclarationVerbs.java @@ -0,0 +1,150 @@ +/* + * Copyright 2026 Darkcollective, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.darkcollective.relix.cli.command; + +import com.darkcollective.relix.cli.command.DeclarationSpec.Noun; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; +import picocli.CommandLine.Unmatched; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; + +/** + * The verbs {@code relix source} and {@code relix connection} share, each declared once + * here and named under each command by a subclass that says which noun it acts on. + */ +final class DeclarationVerbs { + + private DeclarationVerbs() { + } + + /** A verb of one of the two commands. */ + abstract static class Verb implements Callable { + + /** The root command, for the run's global options. */ + abstract RelixCommand root(); + + /** What the verb acts on. */ + abstract Noun noun(); + } + + /** {@code add NAME KIND [LOCATION] [--OPTION=VALUE…]}. */ + abstract static class Add extends Verb { + + @Parameters(index = "0", paramLabel = "NAME", description = "The name to declare.") + String name; + + @Parameters(index = "1", paramLabel = "KIND", + description = "What it is from: a kind (csv, json, database, http, generator), or for a source a connection's name.") + String from; + + @Parameters(index = "2", arity = "0..1", paramLabel = "LOCATION", + description = "The file it reads, relative to the working directory; for csv and json, and a file connection's path.") + String location; + + @Option(names = "--global", + description = "Declare it in ~/.relix, which every directory under your home sees, rather than the nearest project.") + boolean global; + + @Option(names = "--infer-schema", description = "Read a sample, and write the schema it has.") + boolean inferSchema; + + @Unmatched + List flags = new ArrayList<>(); + + @Override + public Integer call() { + Invocation invocation = inferSchema ? root().invocation(false) : root().writing(); + return Declarations.add(invocation, noun(), name, from, location, flags, global, inferSchema); + } + } + + /** {@code ls}. */ + abstract static class Ls extends Verb { + + @Mixin + FormatOptions formatting; + + @Override + public Integer call() { + return Declarations.ls(root().writing(), noun(), formatting); + } + } + + /** {@code edit NAME [--OPTION=VALUE…] [--unset=OPTION…] | --editor}. */ + abstract static class Edit extends Verb { + + @Parameters(index = "0", paramLabel = "NAME", description = "The declaration to change.") + String name; + + @Option(names = "--unset", paramLabel = "OPTION", description = "Remove OPTION. Repeatable.") + List unset = new ArrayList<>(); + + @Option(names = "--infer-schema", description = "Read a sample, and write the schema it has in place of the one there.") + boolean inferSchema; + + @Option(names = "--editor", + description = "Open the file in $VISUAL or $EDITOR at the declaration's line instead, wherever it is.") + boolean editor; + + @Unmatched + List flags = new ArrayList<>(); + + /** The new file a file source reads, for the subclass that offers it. */ + String location() { + return null; + } + + @Override + public Integer call() { + Invocation invocation = inferSchema ? root().invocation(false) : root().writing(); + return Declarations.edit(invocation, noun(), name, flags, unset, location(), inferSchema, editor); + } + } + + /** {@code rm NAME [--force]}. */ + abstract static class Rm extends Verb { + + @Parameters(index = "0", paramLabel = "NAME", description = "The declaration to remove.") + String name; + + @Option(names = "--force", description = "Remove it though other declarations read it.") + boolean force; + + @Override + public Integer call() { + return Declarations.rm(root().writing(), noun(), name, force); + } + } + + /** {@code test [NAME…]}. */ + abstract static class Test extends Verb { + + @Parameters(arity = "0..*", paramLabel = "NAME", description = "The declarations to test (default: all).") + List names = new ArrayList<>(); + + @Mixin + FormatOptions formatting; + + @Override + public Integer call() { + return Declarations.test(root().invocation(true), noun(), names, formatting); + } + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/Declarations.java b/src/main/java/com/darkcollective/relix/cli/command/Declarations.java new file mode 100644 index 0000000..16ef430 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/Declarations.java @@ -0,0 +1,754 @@ +/* + * Copyright 2026 Darkcollective, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.darkcollective.relix.cli.command; + +import com.darkcollective.relix.ast.Spelling; +import com.darkcollective.relix.cli.CommandFailure; +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.catalog.Catalog; +import com.darkcollective.relix.cli.catalog.CatalogFile; +import com.darkcollective.relix.cli.catalog.Declaration; +import com.darkcollective.relix.cli.command.DeclarationSpec.Noun; +import com.darkcollective.relix.cli.render.ScriptFormatter; +import com.darkcollective.relix.embed.Relix; +import com.darkcollective.relix.embed.RelixException; +import com.darkcollective.relix.embed.Tuple; +import com.darkcollective.relix.lang.ast.ConnectionDeclaration; +import com.darkcollective.relix.lang.ast.ScriptPrinter; +import com.darkcollective.relix.lang.ast.SourceDeclaration; +import com.darkcollective.relix.lang.ast.Statement; +import com.darkcollective.relix.lang.ast.source.ConnectionTableSourceConfig; +import com.darkcollective.relix.lang.ast.source.CsvFileSourceConfig; +import com.darkcollective.relix.lang.ast.source.DatabaseSourceConfig; +import com.darkcollective.relix.lang.ast.source.GeneratorSourceConfig; +import com.darkcollective.relix.lang.ast.source.HttpSourceConfig; +import com.darkcollective.relix.lang.ast.source.JsonFileSourceConfig; +import com.darkcollective.relix.symbol.ColumnDefinition; +import com.darkcollective.relix.symbol.ScalarType; +import com.darkcollective.relix.symbol.Schema; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * What {@code relix source} and {@code relix connection} do: declare, list, change, remove + * and test the sources and connections of the catalog, without a catalog file written by + * hand. + * + *

Each {@code add} writes one declaration to a file of its own, + * {@code catalog/00-source-.relix} or {@code catalog/00-connection-.relix}, as + * {@code relix fmt} prints it. One declaration to a file is what makes {@code edit} and + * {@code rm} safe: they change or remove a file that holds the one declaration and nothing + * else, and report a declaration that shares its file with others rather than touch it. + * Every write follows the trust rule of {@link ProjectWrite}. + */ +final class Declarations { + + /** The table a connection test reads, which no database should have. */ + private static final String PROBE_TABLE = "RELIX_CONNECTION_TEST_PROBE"; + + private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{([A-Za-z_][A-Za-z0-9_]*)}"); + private static final Pattern FLAG = Pattern.compile("--([^=]+)(=(.*))?", Pattern.DOTALL); + private static final Pattern PLAIN = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + + private Declarations() { + } + + // ── add ────────────────────────────────────────────────────────────────────────── + + /** + * Declares a source or a connection in a file of its own. + * + * @param invocation the run + * @param noun what is declared + * @param name its name + * @param from its kind, or the connection a source reads + * @param location the file a file kind reads, or {@code null} + * @param flags the declaration's options, as {@code --key=value} or {@code --key} + * @param global whether to write {@code ~/.relix} rather than the nearest project + * @param inferSchema whether to read a sample and write the schema it has + * @return the exit status + */ + static int add(Invocation invocation, Noun noun, String name, String from, String location, + List flags, boolean global, boolean inferSchema) { + Path level = ProjectWrite.level(invocation, global, "--global"); + Path catalogDir = level.resolve(Catalog.DIRECTORY); + Path file = catalogDir.resolve("00-" + noun.word() + "-" + name + Catalog.EXTENSION); + DeclarationSpec spec = DeclarationSpec.of(noun, name, from); + Catalog catalog = invocation.catalog(); + if (noun == Noun.SOURCE) { + requireKind(catalog, from); + if (DeclarationSpec.FILE_KINDS.contains(from) && location == null) { + throw new CommandFailure(ExitCode.USAGE, "LOCATION is required: a " + from + + " source names the file it reads"); + } + if (!DeclarationSpec.FILE_KINDS.contains(from) && location != null) { + throw new CommandFailure(ExitCode.USAGE, "a " + from + " source takes no LOCATION;" + + " give its options as flags, such as --table=NAME"); + } + if (location != null) { + spec.location(relocated(invocation, catalogDir, location)); + } + } else if (location != null) { + spec.set("path", relocated(invocation, catalogDir, location)); + } + applyFlags(invocation, catalogDir, spec, flags); + if (inferSchema) { + inferInto(invocation, catalog, spec, file); + } + Statement statement = spec.parse(file.toString()); + for (Declaration existing : declarationsOf(catalog, noun, name)) { + if (level.equals(existing.file().level())) { + throw new CommandFailure(ExitCode.USAGE, name + " is already declared here, at " + + where(existing) + "; change it with relix " + noun.word() + " edit " + name); + } + } + if (Files.exists(file)) { + throw new CommandFailure(ExitCode.USAGE, file + " already exists"); + } + for (Declaration existing : declarationsOf(catalog, noun, name)) { + if (existing.file().trusted() && catalog.wins(existing)) { + invocation.reporter().warning(name + " is declared at " + where(existing) + + " too; this declaration is nearer, and replaces it below " + level.getParent()); + } + } + String text = format(spec, statement, file); + ProjectWrite.keepingTrust(invocation, level, () -> write(file, text)); + invocation.reporter().info("declared " + noun.word() + " " + name + " in " + file); + return ExitCode.SUCCESS.status(); + } + + /** + * Refuses a source kind that is neither one the language has nor a declared connection. + */ + private static void requireKind(Catalog catalog, String from) { + if (DeclarationSpec.SOURCE_KINDS.contains(from)) { + return; + } + boolean connection = catalog.declarations(from).stream() + .anyMatch(d -> d.kind() == Declaration.Kind.CONNECTION && d.file().trusted() && catalog.wins(d)); + if (!connection) { + throw new CommandFailure(ExitCode.USAGE, "'" + from + "' is neither a source kind (" + + String.join(", ", new TreeSet<>(DeclarationSpec.SOURCE_KINDS)) + + ") nor a connection the catalog declares; declare it with relix connection add"); + } + } + + /** Sets a declaration's options from {@code --key=value} and {@code --key} flags. */ + private static void applyFlags(Invocation invocation, Path catalogDir, DeclarationSpec spec, List flags) { + for (String flag : flags) { + Matcher m = FLAG.matcher(flag); + if (!m.matches()) { + throw new CommandFailure(ExitCode.USAGE, "'" + flag + "': expected --OPTION=VALUE, or --OPTION" + + " for true; a value is joined to its option with ="); + } + String key = m.group(1); + String value = m.group(2) == null ? "true" : m.group(3); + if (key.equals("path") && spec.noun() == Noun.CONNECTION) { + value = relocated(invocation, catalogDir, value); + } + spec.set(key, value); + } + } + + /** + * A relative file path as the catalog file must write it: the user names it from where + * they stand, and the declaration resolves it against the catalog file's directory. + */ + static String relocated(Invocation invocation, Path catalogDir, String location) { + if (location.contains("://") || location.startsWith("${") || location.startsWith("file:") + || Path.of(location).isAbsolute()) { + return location; + } + Path target = invocation.directory().resolve(location).normalize(); + return catalogDir.relativize(target).toString().replace('\\', '/'); + } + + private static String format(DeclarationSpec spec, Statement statement, Path file) { + String printed = ScriptPrinter.print(statement); + try { + return ScriptFormatter.format(printed, file.toString(), Spelling.GLYPHS); + } catch (RuntimeException e) { + // The printed form always parses; this is the engine's to explain if not. + throw new IllegalStateException("cannot format " + spec.text() + ": " + e.getMessage(), e); + } + } + + private static void write(Path file, String text) { + try { + Files.createDirectories(file.getParent()); + Files.writeString(file, text, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new CommandFailure(ExitCode.ENVIRONMENT, file + ": cannot write: " + e.getMessage()); + } + } + + // ── ls ─────────────────────────────────────────────────────────────────────────── + + /** + * Lists the sources or connections the catalog declares, each as the declaration + * that won. + * + * @param invocation the run + * @param noun which + * @param formatting how the rows are written + * @return the exit status + */ + static int ls(Invocation invocation, Noun noun, FormatOptions formatting) { + Catalog catalog = invocation.catalog(); + Listing listing = new Listing("name", ScalarType.STRING, "kind", ScalarType.STRING, + "location", ScalarType.STRING, "file", ScalarType.STRING); + for (Declaration declaration : winners(catalog, noun)) { + String[] described = describe(declaration.statement()); + listing.add(declaration.name(), described[0], described[1], declaration.file().path().toString()); + } + listing.print(invocation, invocation.format(formatting), noun.word() + "s"); + return ExitCode.SUCCESS.status(); + } + + /** A declaration's kind and location, as {@code ls} lists them. */ + private static String[] describe(Statement statement) { + if (statement instanceof ConnectionDeclaration c) { + Map p = c.properties(); + return new String[] {c.connectorType(), p.getOrDefault("url", p.get("path"))}; + } + SourceDeclaration s = (SourceDeclaration) statement; + return switch (s.config()) { + case CsvFileSourceConfig csv -> new String[] {"csv", csv.path()}; + case JsonFileSourceConfig json -> new String[] {"json", json.path()}; + case DatabaseSourceConfig db -> new String[] {"database", db.table()}; + case ConnectionTableSourceConfig table -> new String[] {table.connection(), table.table()}; + case HttpSourceConfig http -> new String[] {"http", http.url()}; + case GeneratorSourceConfig generator -> new String[] {"generator", generator.generatorName()}; + }; + } + + // ── edit ───────────────────────────────────────────────────────────────────────── + + /** + * Changes a declaration's options, or opens it in {@code $EDITOR}. + * + * @param invocation the run + * @param noun what it declares + * @param name its name + * @param flags options to set, as {@code --key=value} + * @param unset options to remove + * @param location a new file for a file kind, or {@code null} + * @param inferSchema whether to read a sample and write the schema it has + * @param editor whether to open the declaration in the user's editor instead + * @return the exit status + */ + static int edit(Invocation invocation, Noun noun, String name, List flags, List unset, + String location, boolean inferSchema, boolean editor) { + Catalog catalog = invocation.catalog(); + Declaration declaration = winner(catalog, noun, name); + Path level = levelOf(declaration); + if (editor) { + if (!flags.isEmpty() || !unset.isEmpty() || location != null || inferSchema) { + throw new CommandFailure(ExitCode.USAGE, "--editor changes nothing else; give it alone"); + } + Path file = declaration.file().path(); + int line = declaration.statement().location().line(); + ProjectWrite.keepingTrust(invocation, level, () -> openEditor(invocation, file, line)); + return ExitCode.SUCCESS.status(); + } + requireAlone(declaration, noun, "edit", "relix " + noun.word() + " edit " + name + " --editor"); + if (flags.isEmpty() && unset.isEmpty() && location == null && !inferSchema) { + throw new CommandFailure(ExitCode.USAGE, "nothing to change: give --OPTION=VALUE, --unset=OPTION," + + " --location, --infer-schema or --editor"); + } + Path file = declaration.file().path(); + Path catalogDir = file.getParent(); + DeclarationSpec spec = DeclarationSpec.read(declaration.statement()); + for (String key : unset) { + if (!spec.unset(key)) { + throw new CommandFailure(ExitCode.USAGE, "--unset=" + key + ": " + name + " sets no " + key); + } + } + if (location != null) { + if (!spec.isFile()) { + throw new CommandFailure(ExitCode.USAGE, "--location: " + name + " reads no file; it is from " + + spec.from()); + } + spec.location(relocated(invocation, catalogDir, location)); + } + applyFlags(invocation, catalogDir, spec, flags); + if (inferSchema) { + spec.unset("schema"); + inferInto(invocation, catalog, spec, file); + } + Statement statement = spec.parse(file.toString()); + String text = format(spec, statement, file); + ProjectWrite.keepingTrust(invocation, level, () -> write(file, text)); + invocation.reporter().info("changed " + noun.word() + " " + name + " in " + file); + return ExitCode.SUCCESS.status(); + } + + private static void openEditor(Invocation invocation, Path file, int line) { + String editor = invocation.host().variable("VISUAL").or(() -> invocation.host().variable("EDITOR")) + .orElseThrow(() -> new CommandFailure(ExitCode.USAGE, + "--editor: set $VISUAL or $EDITOR to the editor to open " + file + " in")); + List command = new ArrayList<>(List.of(editor.strip().split("\\s+"))); + command.add("+" + Math.max(line, 1)); + command.add(file.toString()); + try { + int status = new ProcessBuilder(command).inheritIO().start().waitFor(); + if (status != 0) { + throw new CommandFailure(ExitCode.ENVIRONMENT, "the editor exited " + status); + } + } catch (IOException e) { + throw new CommandFailure(ExitCode.ENVIRONMENT, "cannot start " + editor + ": " + e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CommandFailure(ExitCode.INTERRUPTED, null); + } + } + + // ── rm ─────────────────────────────────────────────────────────────────────────── + + /** + * Removes a declaration's file. + * + * @param invocation the run + * @param noun what it declares + * @param name its name + * @param force whether to remove it though other declarations read it + * @return the exit status + */ + static int rm(Invocation invocation, Noun noun, String name, boolean force) { + Catalog catalog = invocation.catalog(); + Declaration declaration = winner(catalog, noun, name); + Path level = levelOf(declaration); + List readers = readers(catalog, declaration); + if (!readers.isEmpty() && !force) { + StringBuilder message = new StringBuilder(name + " is read by"); + for (Declaration reader : readers) { + message.append("\n ").append(reader.name()).append(" (").append(where(reader)).append(')'); + } + throw new CommandFailure(ExitCode.USAGE, message.append("\nremove those first, or give --force").toString()); + } + requireAlone(declaration, noun, "remove", "edit it by hand: relix " + noun.word() + " edit " + + name + " --editor"); + Path file = declaration.file().path(); + ProjectWrite.keepingTrust(invocation, level, () -> { + try { + Files.delete(file); + } catch (IOException e) { + throw new CommandFailure(ExitCode.ENVIRONMENT, file + ": cannot remove: " + e.getMessage()); + } + }); + invocation.reporter().info("removed " + noun.word() + " " + name + " (" + file + ")"); + declarationsOf(catalog, noun, name).stream() + .filter(d -> d.statement() != declaration.statement() && d.file().trusted()) + .findFirst() + .ifPresent(next -> invocation.reporter().info(name + " now comes from " + where(next))); + return ExitCode.SUCCESS.status(); + } + + /** + * The winning declarations that read a declaration's name: a view or source that names + * a relation, a source or dotted reference that names a connection. Read from the + * engine's printing of each, with its strings left out, so a name in a string or a + * comment is not taken for a reference. + */ + private static List readers(Catalog catalog, Declaration target) { + Pattern reference = Pattern.compile("(? readers = new ArrayList<>(); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + for (CatalogFile file : catalog.files()) { + if (!file.trusted()) { + continue; + } + for (Declaration declaration : file.declarations()) { + Statement statement = declaration.statement(); + if (statement == target.statement() || !catalog.wins(declaration) || !seen.add(statement)) { + continue; + } + String text = ScriptPrinter.print(statement).replaceAll("\"(\\\\.|[^\"\\\\])*\"", "\"\"") + .replaceAll("`[^`]*`", "``"); + // A declaration's own name, after its keyword, is not a reference. + text = text.replaceFirst("^\\s*(source|connection)\\s+[A-Za-z_][A-Za-z0-9_]*", ""); + if (reference.matcher(text).find()) { + readers.add(declaration); + } + } + } + return readers; + } + + // ── test ───────────────────────────────────────────────────────────────────────── + + /** One declaration's test: whether it passed, and what is wrong when not. */ + private record Outcome(String status, String problem) { + static final Outcome OK = new Outcome("ok", null); + + static Outcome failed(String problem) { + return new Outcome("failed", problem); + } + } + + /** + * Tests sources, by reading a row of each, or connections, by opening each. + * + * @param invocation the run, with its profile + * @param noun which + * @param names the declarations to test; all of them when empty + * @param formatting how the rows are written + * @return 0 when every one passed, else 5 + */ + static int test(Invocation invocation, Noun noun, List names, FormatOptions formatting) { + Catalog catalog = invocation.catalog(); + List tested = new ArrayList<>(); + if (names.isEmpty()) { + tested.addAll(winners(catalog, noun)); + } else { + for (String name : names) { + tested.add(winner(catalog, noun, name)); + } + } + invocation.loadInstalledDrivers(); + Listing listing = new Listing("name", ScalarType.STRING, "status", ScalarType.STRING, + "problem", ScalarType.STRING); + boolean passed = true; + try (Relix session = invocation.session(invocation.directory()).build()) { + catalog.define(session); + for (Declaration declaration : tested) { + Outcome outcome = noun == Noun.SOURCE + ? testSource(invocation, catalog, session, declaration) + : testConnection(invocation, declaration); + passed &= !outcome.status().equals("failed"); + listing.add(declaration.name(), outcome.status(), outcome.problem()); + } + } catch (RelixException e) { + throw new CommandFailure(ExitCode.of(e), "the catalog does not analyse: " + e.getMessage()); + } + listing.print(invocation, invocation.format(formatting), "tests"); + return passed ? ExitCode.SUCCESS.status() : ExitCode.ENVIRONMENT.status(); + } + + private static Outcome testSource(Invocation invocation, Catalog catalog, Relix session, Declaration declaration) { + SourceDeclaration source = (SourceDeclaration) declaration.statement(); + List involved = new ArrayList<>(List.of(source)); + if (source.config() instanceof ConnectionTableSourceConfig table) { + connection(catalog, table.connection()).ifPresent(c -> involved.addFirst(c.statement())); + } + Optional unresolved = unresolved(invocation, involved); + if (unresolved.isPresent()) { + return Outcome.failed(unresolved.get()); + } + try (Stream rows = session.relation(declaration.name()).limit(1).stream()) { + rows.forEach(row -> { }); + } catch (RelixException e) { + return Outcome.failed(oneLine(e.getMessage())); + } + List declared = session.relation(declaration.name()).schema().columns(); + if (declared.isEmpty() || !hasSchema(source)) { + return Outcome.OK; + } + Optional> read; + try { + read = observed(invocation, catalog, DeclarationSpec.read(source), declaration.file().path()); + } catch (CommandFailure | RelixException e) { + return Outcome.OK; + } + return read.map(columns -> mismatch(declared, columns)).orElse(Outcome.OK); + } + + private static boolean hasSchema(SourceDeclaration source) { + return DeclarationSpec.read(source).options().containsKey("schema"); + } + + /** What a declared schema gets wrong about the columns read. */ + private static Outcome mismatch(List declared, List read) { + Map byName = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + read.forEach(c -> byName.put(c.name(), c)); + List problems = new ArrayList<>(); + for (ColumnDefinition column : declared) { + ColumnDefinition found = byName.get(column.name()); + if (found == null) { + problems.add("no column " + column.name() + " in what was read"); + } else if (column.type() instanceof ScalarType want && found.type() instanceof ScalarType got + && want != ScalarType.ANY && want != ScalarType.STRING && got != ScalarType.ANY && want != got) { + problems.add(column.name() + " is declared " + want + " but reads as " + got); + } + } + return problems.isEmpty() ? Outcome.OK : Outcome.failed(String.join("; ", problems)); + } + + private static Outcome testConnection(Invocation invocation, Declaration declaration) { + ConnectionDeclaration connection = (ConnectionDeclaration) declaration.statement(); + Optional unresolved = unresolved(invocation, List.of(connection)); + if (unresolved.isPresent()) { + return Outcome.failed(unresolved.get()); + } + if (!connection.connectorType().equals("jdbc")) { + return new Outcome("untested", "only a database connection can be opened to test it;" + + " relix source test reads its sources"); + } + // A probe over a table no database has: the connection opened if the database + // answers that the table is not there, and did not if anything else gave way. + String probe = "source " + declaration.name() + "_relix_probe from " + declaration.name() + + " { table: \"" + PROBE_TABLE + "\", schema: { probe: ANY } };"; + try (Relix session = invocation.session(declaration.file().path().getParent()).build()) { + session.define(connection); + session.define(probe); + try (Stream rows = session.relation(declaration.name() + "_relix_probe").limit(1).stream()) { + rows.forEach(row -> { }); + } + return Outcome.OK; + } catch (RelixException e) { + return answeredByDatabase(e) ? Outcome.OK : Outcome.failed(oneLine(e.getMessage())); + } + } + + /** Whether a failure is the database's answer to a query, rather than no database at all. */ + private static boolean answeredByDatabase(Throwable failure) { + for (Throwable t = failure; t != null; t = t.getCause()) { + if (t instanceof SQLException sql && sql.getSQLState() != null && sql.getSQLState().startsWith("42")) { + return true; + } + if (t.getCause() == t) { + break; + } + } + return false; + } + + /** The first placeholder in the statements that has no value, said as a problem. */ + private static Optional unresolved(Invocation invocation, List statements) { + for (Statement statement : statements) { + Matcher m = PLACEHOLDER.matcher(ScriptPrinter.print(statement)); + while (m.find()) { + if (invocation.placeholder(m.group(1)).isEmpty()) { + return Optional.of("${" + m.group(1) + "} has no value: set it in a profile (-P)," + + " the environment or -D"); + } + } + } + return Optional.empty(); + } + + private static String oneLine(String message) { + String line = message == null ? "failed" : message.strip(); + int end = line.indexOf('\n'); + return end < 0 ? line : line.substring(0, end).strip(); + } + + // ── schema inference ───────────────────────────────────────────────────────────── + + /** + * Reads a sample of what a source reads and writes the schema it has into it. + * + * @throws CommandFailure with {@link ExitCode#USAGE} for a kind that cannot be read + * this way, and with {@link ExitCode#ENVIRONMENT} when the + * sample cannot be read + */ + private static void inferInto(Invocation invocation, Catalog catalog, DeclarationSpec spec, Path file) { + if (spec.noun() != Noun.SOURCE) { + throw new CommandFailure(ExitCode.USAGE, "--infer-schema: a connection has no schema; its sources do"); + } + if (spec.options().containsKey("schema")) { + throw new CommandFailure(ExitCode.USAGE, "--infer-schema writes the schema; give it or --schema, not both"); + } + if (spec.from().equals("json")) { + throw new CommandFailure(ExitCode.USAGE, "--infer-schema: a json source declares no schema;" + + " its rows are read as they come (schema-on-read)"); + } + if (spec.options().containsKey("columns")) { + throw new CommandFailure(ExitCode.USAGE, "--infer-schema writes a schema, and a source declares" + + " a schema or its columns, not both"); + } + List columns; + try { + columns = observed(invocation, catalog, spec, file).orElseThrow(() -> new CommandFailure(ExitCode.USAGE, + "--infer-schema reads csv files and database tables, not a " + spec.from() + " source")); + } catch (RelixException e) { + throw new CommandFailure(ExitCode.ENVIRONMENT, "--infer-schema: cannot read " + spec.name() + ": " + + oneLine(e.getMessage())); + } + if (columns.isEmpty()) { + throw new CommandFailure(ExitCode.ENVIRONMENT, "--infer-schema: read no columns for " + spec.name()); + } + StringBuilder schema = new StringBuilder("{ "); + String separator = ""; + for (ColumnDefinition column : columns) { + String type = column.type() instanceof ScalarType scalar ? scalar.name() : ScalarType.ANY.name(); + schema.append(separator).append(columnName(column.name())).append(": ").append(type); + separator = ", "; + } + spec.setText("schema", schema.append(" }").toString()); + } + + private static String columnName(String name) { + return PLAIN.matcher(name).matches() ? name : "`" + name.replace("`", "``") + "`"; + } + + /** + * The columns a source reads, as the data has them rather than as it declares them: + * a CSV file's heading inferred from its records, a table's as its database describes + * it. + * + * @return the columns, or empty for a kind that cannot be read this way + * @throws RelixException when the data cannot be read + * @throws CommandFailure when the source's connection is not declared + */ + private static Optional> observed(Invocation invocation, Catalog catalog, + DeclarationSpec source, Path file) { + DeclarationSpec bare = source.copy(); + bare.unset("schema"); + Path base = file.getParent(); + switch (source.from()) { + case "csv" -> { + Statement statement = bare.parse(file.toString()); + try (Relix session = invocation.session(base).build()) { + session.define(statement); + return Optional.of(session.relation(source.name()).schema().columns()); + } + } + case "database" -> { + String table = table(source); + DeclarationSpec connection = DeclarationSpec.of(Noun.CONNECTION, "relix_infer", "database"); + for (String key : List.of("url", "user", "password", "dialect")) { + String value = source.options().get(key); + if (value != null) { + connection.setText(key, value); + } + } + try (Relix session = invocation.session(base).build()) { + session.define(connection.parse(file.toString())); + return Optional.of(tableColumns(session, "relix_infer", table)); + } + } + case "json", "http", "generator" -> { + return Optional.empty(); + } + default -> { + String table = table(source); + Declaration connection = connection(catalog, source.from()).orElseThrow(() -> + new CommandFailure(ExitCode.USAGE, "the catalog declares no connection " + source.from())); + try (Relix session = invocation.session(connection.file().path().getParent()).build()) { + session.define(connection.statement()); + return Optional.of(tableColumns(session, source.from(), table)); + } + } + } + } + + private static String table(DeclarationSpec source) { + String table = source.option("table").orElseThrow(() -> new CommandFailure(ExitCode.USAGE, + "--table is required: a " + source.from() + " source names the table it reads")); + if (!PLAIN.matcher(table).matches()) { + throw new CommandFailure(ExitCode.USAGE, "--infer-schema reads a table whose name is a plain name," + + " not '" + table + "'; give --schema instead"); + } + return table; + } + + private static List tableColumns(Relix session, String connection, String table) { + Schema schema = session.relation(connection + "." + table).schema(); + return schema.columns(); + } + + // ── finding declarations ───────────────────────────────────────────────────────── + + /** Every declaration of a name that competes with a source or a connection, nearest first. */ + private static List declarationsOf(Catalog catalog, Noun noun, String name) { + return catalog.declarations(name).stream() + .filter(d -> noun == Noun.CONNECTION + ? d.kind() == Declaration.Kind.CONNECTION + : d.kind().sameSpace(Declaration.Kind.SOURCE)) + .toList(); + } + + /** The winning sources or connections, by name. */ + private static List winners(Catalog catalog, Noun noun) { + Declaration.Kind kind = noun == Noun.SOURCE ? Declaration.Kind.SOURCE : Declaration.Kind.CONNECTION; + Map byName = new TreeMap<>(); + for (CatalogFile file : catalog.files()) { + for (Declaration declaration : file.declarations()) { + if (declaration.kind() == kind && file.trusted() && catalog.wins(declaration)) { + byName.put(declaration.name(), declaration); + } + } + } + return List.copyOf(byName.values()); + } + + /** + * The declaration of a name that won, which must declare what {@code noun} says. + * + * @throws CommandFailure with {@link ExitCode#USAGE} when there is none, or it is + * something else + */ + private static Declaration winner(Catalog catalog, Noun noun, String name) { + Declaration winner = declarationsOf(catalog, noun, name).stream() + .filter(d -> d.file().trusted() && catalog.wins(d)) + .findFirst() + .orElseThrow(() -> new CommandFailure(ExitCode.USAGE, "the catalog declares no " + noun.word() + + " " + name)); + Declaration.Kind kind = noun == Noun.SOURCE ? Declaration.Kind.SOURCE : Declaration.Kind.CONNECTION; + if (winner.kind() != kind) { + throw new CommandFailure(ExitCode.USAGE, name + " is a " + winner.kind().label() + ", not a " + + noun.word() + ", at " + where(winner)); + } + return winner; + } + + private static Optional connection(Catalog catalog, String name) { + return catalog.declarations(name).stream() + .filter(d -> d.kind() == Declaration.Kind.CONNECTION && d.file().trusted() && catalog.wins(d)) + .findFirst(); + } + + /** The {@code .relix/} directory a declaration's file is in; a {@code --catalog} file is not changed. */ + private static Path levelOf(Declaration declaration) { + Path level = declaration.file().level(); + if (level == null) { + throw new CommandFailure(ExitCode.USAGE, declaration.name() + " is declared at " + where(declaration) + + ", a file named with --catalog or $RELIX_CATALOG_PATH; change it there"); + } + return level; + } + + /** Refuses a declaration that shares its file with others. */ + private static void requireAlone(Declaration declaration, Noun noun, String verb, String instead) { + if (declaration.file().statements().size() > 1) { + throw new CommandFailure(ExitCode.USAGE, where(declaration) + " declares " + declaration.name() + + " beside other declarations, which relix " + noun.word() + " does not " + verb + + " for you; " + instead); + } + } + + /** {@code FILE:LINE}, as {@code relix catalog where} gives a declaration's place. */ + private static String where(Declaration declaration) { + return declaration.file().path() + ":" + declaration.statement().location().line(); + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/InitCommand.java b/src/main/java/com/darkcollective/relix/cli/command/InitCommand.java new file mode 100644 index 0000000..c17fcea --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/InitCommand.java @@ -0,0 +1,145 @@ +/* + * Copyright 2026 Darkcollective, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.darkcollective.relix.cli.command; + +import com.darkcollective.relix.cli.CommandFailure; +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.catalog.Catalog; +import com.darkcollective.relix.cli.config.Profiles; +import com.darkcollective.relix.cli.config.RelixDirectories; +import com.darkcollective.relix.cli.config.Relixrc; +import com.darkcollective.relix.cli.render.JsonText; +import com.darkcollective.relix.cli.render.OutputFormat; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; +import picocli.CommandLine.ParentCommand; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.Callable; + +/** + * {@code relix init}: makes a project's {@code .relix/} directory, so that its first + * query needs no file written by hand (design §5.1). + * + *

It writes the catalog directory, a {@code profiles.json} only its owner can read, + * empty but for the profile {@code -P} names, a {@code .gitignore} beside it that keeps the profiles out of version control, + * and, when asked, {@code relixrc} and the {@code root} marker. Then it trusts what it + * wrote, as {@code relix catalog trust} would, so the next run loads it without a warning. + * A directory that already has a {@code .relix/} is refused: running it again is never + * how a project is repaired. + */ +@Command(name = "init", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Creates the .relix/ directory of DIR (default: the working directory): an empty", + "catalog/, a profiles.json only you can read, a .gitignore that keeps", + "profiles.json out of version control, and relixrc and root when asked for:", + "-o and -P (the global option) write a relixrc with those defaults, and the", + "profile -P names is defined, empty, in profiles.json. Then trusts the new", + "directory, so the next run there loads it. Writes nothing to standard output."}, + exitCodeListHeading = "%nExit status:%n", + exitCodeList = { + " 0:success", + " 2:DIR already has a .relix/ directory, or an option is not valid", + " 5:a file cannot be written"}) +final class InitCommand implements Callable { + + /** What the {@code .gitignore} inside a new {@code .relix/} holds. */ + static final String GITIGNORE = Profiles.FILE_NAME + "\n"; + + @ParentCommand + RelixCommand root; + + @Parameters(paramLabel = "DIR", arity = "0..1", + description = "The project's directory, made if it does not exist.") + Path directory; + + @Option(names = "--root", + description = "Write the root marker: discovery stops at this project, and the directories above it are not read.") + boolean rootMarker; + + @Option(names = {"-o", "--output"}, paramLabel = "FORMAT", + description = "Write a relixrc whose default output format is FORMAT.") + String output; + + @Override + public Integer call() { + Invocation invocation = root.writing(); + String profile = root.profileOption(); + if (output != null) { + try { + OutputFormat.fromString(output.strip()); + } catch (IllegalArgumentException e) { + throw new CommandFailure(ExitCode.USAGE, "-o: " + e.getMessage()); + } + } + if (profile != null && profile.isBlank()) { + throw new CommandFailure(ExitCode.USAGE, "--profile: a profile needs a name"); + } + Path project = directory == null ? invocation.directory() + : invocation.directory().resolve(directory).normalize(); + Path level = project.resolve(RelixDirectories.NAME); + if (Files.exists(level)) { + throw new CommandFailure(ExitCode.USAGE, level + " already exists;" + + " relix init makes a new project and never changes one"); + } + Path home = invocation.host().home(); + try { + Files.createDirectories(level.resolve(Catalog.DIRECTORY)); + // The profile relixrc names is defined, empty, so that the next run finds it + // before relix profiles set has given it a value. + Profiles.writePrivate(level.resolve(Profiles.FILE_NAME), profile == null ? "{}\n" + : "{\n " + JsonText.quote(profile.strip()) + ": {}\n}\n"); + write(level.resolve(".gitignore"), GITIGNORE); + if (output != null || profile != null) { + StringBuilder relixrc = new StringBuilder(); + if (output != null) { + relixrc.append(Relixrc.OUTPUT).append(" = ").append(output.strip()).append('\n'); + } + if (profile != null) { + relixrc.append(Relixrc.PROFILE).append(" = ").append(profile.strip()).append('\n'); + } + write(level.resolve(Relixrc.FILE_NAME), relixrc.toString()); + } + if (rootMarker) { + write(level.resolve(RelixDirectories.ROOT_MARKER), + "# The walk up for .relix/ directories stops here.\n"); + } + } catch (IOException e) { + throw new CommandFailure(ExitCode.ENVIRONMENT, level + ": cannot write: " + e.getMessage()); + } + invocation.reporter().info("created " + level); + if (!level.equals(home.resolve(RelixDirectories.NAME))) { + invocation.trust().trust(level); + invocation.reporter().info("trusting " + level); + } + if (!project.startsWith(home)) { + invocation.reporter().warning(project + " is outside " + home + ": there, a run reads only" + + " the working directory's own .relix/, so it is used only from " + project + " itself"); + } + return ExitCode.SUCCESS.status(); + } + + private static void write(Path file, String text) throws IOException { + Files.writeString(file, text, StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/Invocation.java b/src/main/java/com/darkcollective/relix/cli/command/Invocation.java index d3e80ac..948e23b 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/Invocation.java +++ b/src/main/java/com/darkcollective/relix/cli/command/Invocation.java @@ -88,6 +88,20 @@ final class Invocation { * the command is the one that trusts them */ Invocation(Host host, GlobalOptions options, boolean announce) { + this(host, options, announce, true); + } + + /** + * A run, settled. + * + * @param host the process + * @param options its global options + * @param announce whether to say which untrusted directories are skipped + * @param profile whether to select the profile {@code -P} names; not for a command + * that writes a project's files, which a profile it does not yet + * define must not stop + */ + Invocation(Host host, GlobalOptions options, boolean announce, boolean profile) { this.host = host; this.options = options; this.reporter = new Reporter(host.err(), verbosity(options)); @@ -102,20 +116,20 @@ final class Invocation { List trusted = levels.stream().filter(trust::trusts).toList(); this.relixrc = options.noCatalog ? Relixrc.none() : Relixrc.read(trusted, reporter::warning); this.catalogFiles = catalogFiles(host, options, directory); - String profile = options.profile != null ? options.profile + String selected = !profile ? null : options.profile != null ? options.profile : host.variable(PROFILE_VARIABLE).or(() -> relixrc.get(Relixrc.PROFILE)).orElse(null); if (announce) { for (Path level : levels) { - if (!trust.trusts(level) && wouldRead(level, options.noCatalog, profile != null)) { + if (!trust.trusts(level) && wouldRead(level, options.noCatalog, selected != null)) { Path project = level.getParent(); reporter.warning("skipping " + level + ", which is not trusted; to load it: " + "relix catalog trust " + shellWord(project.toString())); } } } - Map values = profile == null + Map values = selected == null ? Map.of() - : Profiles.select(profile, trusted); + : Profiles.select(selected, trusted); this.placeholders = Placeholders.resolver(options.defines, values, host.environment()); } @@ -162,6 +176,17 @@ FormatOptions.Format format(FormatOptions formatting) { return formatting.settle(host, relixrc.get(Relixrc.OUTPUT)); } + /** + * The value a {@code ${VAR}} placeholder resolves to in this run: from {@code -D}, the + * profile, then the environment. + * + * @param name the placeholder's name + * @return its value, when it has one + */ + Optional placeholder(String name) { + return placeholders.apply(name); + } + /** What the user trusts. */ Trust trust() { return trust; diff --git a/src/main/java/com/darkcollective/relix/cli/command/ProfilesCommand.java b/src/main/java/com/darkcollective/relix/cli/command/ProfilesCommand.java new file mode 100644 index 0000000..6b09eab --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/ProfilesCommand.java @@ -0,0 +1,368 @@ +/* + * Copyright 2026 Darkcollective, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.darkcollective.relix.cli.command; + +import com.darkcollective.relix.cli.CommandFailure; +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.config.Profiles; +import com.darkcollective.relix.cli.config.RelixDirectories; +import com.darkcollective.relix.symbol.ScalarType; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; +import picocli.CommandLine.ParentCommand; + +import java.io.Console; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * {@code relix profiles}: the named profiles of {@code ${VAR}} values (design §5.5), and + * setting them, so that a credential never has to be typed into a {@code profiles.json}. + * + *

A value never comes from an argument, where it would land in shell history and in + * {@code ps}: {@code set} prompts for it on a terminal, without echoing it, and reads it + * from standard input otherwise. {@code ls} never prints a value; {@code get} prints one, + * because a pipeline may need it. + * + *

{@code set} and {@code rm} change the nearest project's {@code profiles.json}, or with + * {@code --user} the user's own, following the trust rule of {@link ProjectWrite}. The file + * is written only its owner can read, and one readable by others is refused, as reading + * it is. + */ +@Command(name = "profiles", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Lists, sets and removes the variables of the profiles $${VAR} placeholders", + "resolve from (-P). Without a subcommand, lists them, as ls does. A value is", + "never taken from an argument, and never listed."}, + subcommands = { + ProfilesCommand.Ls.class, ProfilesCommand.Set.class, ProfilesCommand.Get.class, + ProfilesCommand.Rm.class}) +final class ProfilesCommand implements Callable { + + private static final Pattern VARIABLE = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + + @ParentCommand + RelixCommand root; + + @Mixin + FormatOptions formatting; + + @Override + public Integer call() { + return list(root.writing(), formatting, false); + } + + /** The options that choose the user's file over the project's. */ + static final class Scope { + @Option(names = "--user", + description = "Use ~/.relix/profiles.json rather than the nearest project's.") + boolean user; + } + + private static int list(Invocation invocation, FormatOptions formatting, boolean user) { + Path own = invocation.host().home().resolve(RelixDirectories.NAME); + List levels = user ? List.of(own) : invocation.levels(); + Listing listing = new Listing("profile", ScalarType.STRING, "variable", ScalarType.STRING, + "file", ScalarType.STRING); + for (Path level : levels) { + Path file = level.resolve(Profiles.FILE_NAME); + Profiles.load(file).forEach((profile, variables) -> { + if (variables.isEmpty()) { + listing.add(profile, null, file.toString()); + } + variables.keySet().forEach(variable -> listing.add(profile, variable, file.toString())); + }); + } + listing.print(invocation, invocation.format(formatting), "profiles"); + return ExitCode.SUCCESS.status(); + } + + private static void requireVariable(String variable) { + if (!VARIABLE.matcher(variable).matches()) { + throw new CommandFailure(ExitCode.USAGE, "'" + variable + + "' is not a variable name: letters, digits and _, not starting with a digit"); + } + } + + /** + * Changes the profiles of the file a command writes, keeping its project's trust, and + * warns when git tracks it. + */ + private static void change(Invocation invocation, boolean user, String what, + Consumer>> edit) { + Path level = ProjectWrite.level(invocation, user); + Path file = level.resolve(Profiles.FILE_NAME); + Map> profiles = Profiles.load(file); + edit.accept(profiles); + ProjectWrite.keepingTrust(invocation, level, () -> Profiles.save(file, profiles)); + invocation.reporter().info(what + " in " + file); + if (tracked(invocation, file)) { + invocation.reporter().warning(file + " is tracked by git, so what it holds may be pushed" + + " with the repository; stop tracking it with: git rm --cached " + + Invocation.shellWord(file.toString())); + } + } + + /** Whether git tracks a file, when git is on the {@code PATH}. */ + private static boolean tracked(Invocation invocation, Path file) { + Path git = invocation.host().variable("PATH").stream() + .flatMap(path -> Arrays.stream(path.split(File.pathSeparator))) + .filter(dir -> !dir.isBlank()) + .flatMap(dir -> Stream.of(Path.of(dir, "git"), Path.of(dir, "git.exe"))) + .filter(Files::isExecutable) + .findFirst().orElse(null); + if (git == null) { + return false; + } + try { + Process process = new ProcessBuilder(git.toString(), "ls-files", "--error-unmatch", + file.getFileName().toString()) + .directory(file.getParent().toFile()) + .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .start(); + process.getOutputStream().close(); + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.destroyForcibly(); + return false; + } + return process.exitValue() == 0; + } catch (IOException e) { + return false; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + /** {@code relix profiles ls}. */ + @Command(name = "ls", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Lists each profile's variables and the file that sets each, outermost first:", + "profile, variable and file, never the value. A profile with no variable is", + "listed once, with an empty variable."}) + static final class Ls implements Callable { + + @ParentCommand + ProfilesCommand profiles; + + @Mixin + Scope scope; + + @Mixin + FormatOptions formatting; + + @Override + public Integer call() { + return list(profiles.root.writing(), formatting, scope.user); + } + } + + /** {@code relix profiles set PROFILE VARIABLE}. */ + @Command(name = "set", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Sets VARIABLE in PROFILE, creating either. The value is read without echoing it", + "when standard input is a terminal, and from standard input otherwise, less one", + "line break at its end; it is never taken from an argument, which would leave it", + "in shell history and ps. Writes the nearest project's profiles.json, or with", + "--user ~/.relix/profiles.json, making it readable by you alone."}, + exitCodeListHeading = "%nExit status:%n", + exitCodeList = { + " 0:success", + " 2:no project here (give --user), a bad name, or no value", + " 5:the file is readable by others, or cannot be written"}) + static final class Set implements Callable { + + @ParentCommand + ProfilesCommand profiles; + + @Parameters(index = "0", paramLabel = "PROFILE", description = "The profile.") + String profile; + + @Parameters(index = "1", paramLabel = "VARIABLE", description = "The variable, as $${VARIABLE} names it.") + String variable; + + @Mixin + Scope scope; + + @Override + public Integer call() { + Invocation invocation = profiles.root.writing(); + requireVariable(variable); + if (profile.isBlank()) { + throw new CommandFailure(ExitCode.USAGE, "a profile needs a name"); + } + // Where the value goes is checked before it is asked for. + ProjectWrite.level(invocation, scope.user); + String value = value(invocation); + change(invocation, scope.user, "set " + variable + " in profile " + profile, + all -> all.computeIfAbsent(profile, p -> new LinkedHashMap<>()).put(variable, value)); + return ExitCode.SUCCESS.status(); + } + + private String value(Invocation invocation) { + if (invocation.host().stdinIsTerminal()) { + Console console = System.console(); + if (console == null) { + throw new CommandFailure(ExitCode.USAGE, "no terminal to ask for the value on;" + + " give it on standard input"); + } + char[] typed = console.readPassword("%s for profile %s: ", variable, profile); + if (typed == null || typed.length == 0) { + throw new CommandFailure(ExitCode.USAGE, "no value given"); + } + return new String(typed); + } + String text; + try { + text = new String(invocation.host().in().readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new CommandFailure(ExitCode.ENVIRONMENT, "cannot read standard input: " + e.getMessage()); + } + if (text.endsWith("\r\n")) { + text = text.substring(0, text.length() - 2); + } else if (text.endsWith("\n")) { + text = text.substring(0, text.length() - 1); + } + if (text.isEmpty()) { + throw new CommandFailure(ExitCode.USAGE, "no value on standard input"); + } + return text; + } + } + + /** {@code relix profiles get PROFILE VARIABLE}. */ + @Command(name = "get", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Prints the value VARIABLE has in PROFILE, as a run under -P PROFILE would", + "resolve it from the profiles.json files (the nearest wins), or with --user from", + "~/.relix/profiles.json alone. The environment and -D are not consulted."}, + exitCodeListHeading = "%nExit status:%n", + exitCodeList = { + " 0:success", + " 2:no such profile, or it does not set VARIABLE", + " 5:a profiles file is readable by others"}) + static final class Get implements Callable { + + @ParentCommand + ProfilesCommand profiles; + + @Parameters(index = "0", paramLabel = "PROFILE", description = "The profile.") + String profile; + + @Parameters(index = "1", paramLabel = "VARIABLE", description = "The variable.") + String variable; + + @Mixin + Scope scope; + + @Override + public Integer call() { + Invocation invocation = profiles.root.writing(); + Path own = invocation.host().home().resolve(RelixDirectories.NAME); + List levels = scope.user ? List.of(own) + : invocation.levels().stream().filter(invocation.trust()::trusts).toList(); + String value = Profiles.select(profile, levels).get(variable); + if (value == null) { + throw new CommandFailure(ExitCode.USAGE, "profile '" + profile + "' does not set " + variable); + } + try { + OutputStream out = invocation.host().out(); + out.write((value + "\n").getBytes(StandardCharsets.UTF_8)); + out.flush(); + } catch (IOException e) { + throw new CommandFailure(ExitCode.ENVIRONMENT, "cannot write standard output: " + e.getMessage()); + } + return ExitCode.SUCCESS.status(); + } + } + + /** {@code relix profiles rm PROFILE [VARIABLE]}. */ + @Command(name = "rm", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Removes VARIABLE from PROFILE, or without VARIABLE the whole profile, in the", + "nearest project's profiles.json, or with --user in ~/.relix/profiles.json."}, + exitCodeListHeading = "%nExit status:%n", + exitCodeList = { + " 0:success", + " 2:no project here, or the file does not set it", + " 5:the file is readable by others, or cannot be written"}) + static final class Rm implements Callable { + + @ParentCommand + ProfilesCommand profiles; + + @Parameters(index = "0", paramLabel = "PROFILE", description = "The profile.") + String profile; + + @Parameters(index = "1", arity = "0..1", paramLabel = "VARIABLE", description = "The variable.") + String variable; + + @Mixin + Scope scope; + + @Override + public Integer call() { + Invocation invocation = profiles.root.writing(); + String what = variable == null ? "removed profile " + profile + : "removed " + variable + " from profile " + profile; + change(invocation, scope.user, what, all -> { + Map variables = all.get(profile); + Path file = ProjectWrite.level(invocation, scope.user).resolve(Profiles.FILE_NAME); + if (variables == null) { + throw new CommandFailure(ExitCode.USAGE, file + " has no profile '" + profile + "'"); + } + if (variable == null) { + all.remove(profile); + } else if (variables.remove(variable) == null) { + throw new CommandFailure(ExitCode.USAGE, "profile '" + profile + "' in " + file + + " does not set " + variable); + } + }); + return ExitCode.SUCCESS.status(); + } + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/ProjectWrite.java b/src/main/java/com/darkcollective/relix/cli/command/ProjectWrite.java new file mode 100644 index 0000000..b8698d0 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/ProjectWrite.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026 Darkcollective, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.darkcollective.relix.cli.command; + +import com.darkcollective.relix.cli.CommandFailure; +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.catalog.Trust; +import com.darkcollective.relix.cli.config.RelixDirectories; + +import java.nio.file.Path; +import java.util.List; + +/** + * Where a command that writes under a {@code .relix/} directory writes, and the trust rule + * every such command follows (design §5.4): a trusted project whose files are as they were + * trusted stays trusted across the command's own change, and any other stays untrusted. + * Which of the two happened is said on standard error. + */ +final class ProjectWrite { + + private ProjectWrite() { + } + + /** + * The {@code .relix/} directory a command writes: the user's own with {@code --user}, + * otherwise the nearest project's. + * + * @param invocation the run + * @param user whether {@code --user} was given + * @return the directory, absolute; it exists, unless it is the user's + * @throws CommandFailure with {@link ExitCode#USAGE} when there is no project + */ + static Path level(Invocation invocation, boolean user) { + return level(invocation, user, "--user"); + } + + /** + * The {@code .relix/} directory a command writes: the user's own when {@code flag} was + * given, otherwise the nearest project's. + * + * @param invocation the run + * @param user whether the flag was given + * @param flag the command's flag for the user's own directory + * @return the directory, absolute; it exists, unless it is the user's + * @throws CommandFailure with {@link ExitCode#USAGE} when there is no project + */ + static Path level(Invocation invocation, boolean user, String flag) { + Path own = invocation.host().home().resolve(RelixDirectories.NAME); + if (user) { + return own; + } + List levels = invocation.levels(); + Path nearest = levels.isEmpty() ? null : levels.getLast(); + if (nearest == null || nearest.equals(own)) { + throw new CommandFailure(ExitCode.USAGE, "no project " + RelixDirectories.NAME + " directory here or above;" + + " make one with relix init, or give " + flag + " for " + own); + } + return nearest; + } + + /** + * Makes a change under a {@code .relix/} directory, keeping its trust as the rule says, + * and says what became of it. + * + * @param invocation the run + * @param level the directory + * @param change what writes its files + */ + static void keepingTrust(Invocation invocation, Path level, Runnable change) { + Trust.Kept kept = invocation.trust().keep(level, change); + switch (kept) { + case OWN -> { + // ~/.relix is the user's own, and always trusted. + } + case TRUSTED -> invocation.reporter().info(level + " is still trusted"); + case UNTRUSTED -> invocation.reporter().warning(level + " was not trusted as it was before this" + + " change, so it is not trusted now; review it, then: relix catalog trust " + + Invocation.shellWord(level.getParent().toString())); + } + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java b/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java index 6fa99a8..4e3a2ba 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java +++ b/src/main/java/com/darkcollective/relix/cli/command/RelixCommand.java @@ -48,8 +48,9 @@ subcommands = { RunCommand.class, CheckCommand.class, ExplainCommand.class, OptimizeCommand.class, TraceCommand.class, BundleCommand.class, IrCommand.class, ProvenanceCommand.class, - FmtCommand.class, CatalogCommand.class, DriversCommand.class, ConnectorsCommand.class, - DocCommand.class, HelpCommand.class, VersionCommand.class, CompletionCommand.class}, + FmtCommand.class, InitCommand.class, CatalogCommand.class, SourceCommand.class, + ConnectionCommand.class, ProfilesCommand.class, DriversCommand.class, ConnectorsCommand.class, DocCommand.class, HelpCommand.class, VersionCommand.class, + CompletionCommand.class}, exitCodeListHeading = "%nExit status:%n", exitCodeList = { " 0:success", @@ -162,6 +163,21 @@ Invocation invocation(boolean announce) { return new Invocation(host, options, announce); } + /** + * This invocation's global options, settled for a command that writes a project's + * files: no profile is selected, and nothing is said of untrusted directories. + * + * @return the run + */ + Invocation writing() { + return new Invocation(host, options, false, false); + } + + /** The profile {@code -P} names on the command line, if it names one. */ + String profileOption() { + return options.profile; + } + /** The command's and the engine's versions, for {@code --version}. */ static final class Version implements IVersionProvider { @Override diff --git a/src/main/java/com/darkcollective/relix/cli/command/SourceCommand.java b/src/main/java/com/darkcollective/relix/cli/command/SourceCommand.java new file mode 100644 index 0000000..c8955be --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/command/SourceCommand.java @@ -0,0 +1,204 @@ +/* + * Copyright 2026 Darkcollective, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.darkcollective.relix.cli.command; + +import com.darkcollective.relix.cli.CommandFailure; +import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.command.DeclarationSpec.Noun; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParentCommand; + +import java.util.concurrent.Callable; + +/** + * {@code relix source}: declares, lists, changes, removes and tests the catalog's sources, + * the relations it reads from files, databases and APIs, from flags rather than a catalog + * file written by hand. Views and {@code def}s have no command: they are Relix code, and an + * editor is the tool for code. + */ +@Command(name = "source", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Declares, lists, changes, removes and tests sources: the relations the catalog", + "reads from files, databases and APIs. Each add writes one declaration to a file", + "of its own, catalog/00-source-NAME.relix, as relix fmt prints it."}, + subcommands = { + SourceCommand.Add.class, SourceCommand.Ls.class, SourceCommand.Edit.class, + SourceCommand.Rm.class, SourceCommand.Test.class}) +final class SourceCommand implements Callable { + + @ParentCommand + RelixCommand root; + + @Override + public Integer call() { + throw new CommandFailure(ExitCode.USAGE, null); + } + + /** {@code relix source add}. */ + @Command(name = "add", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Declares the source NAME, from KIND, a csv or json file at LOCATION, a database,", + "an http API or a generator, or from a connection the catalog declares. Every", + "other option of the declaration is a flag: --OPTION=VALUE, or --OPTION for true.", + "true, false, a number, and a { … } or [ … ] block are written as they are;", + "anything else is a string. A secret is written as a $${NAME} placeholder, never", + "its value. A name an outer level declares is allowed, and warned about.", + "Writes the nearest project's catalog, or with --global ~/.relix's."}, + exitCodeListHeading = "%nExit status:%n", + exitCodeList = { + " 0:success", + " 2:a required option is missing, a value is a secret, or NAME is declared here already", + " 5:--infer-schema cannot read the data, or the file cannot be written"}) + static final class Add extends DeclarationVerbs.Add { + @ParentCommand + SourceCommand source; + + @Override + RelixCommand root() { + return source.root; + } + + @Override + Noun noun() { + return Noun.SOURCE; + } + } + + /** {@code relix source ls}. */ + @Command(name = "ls", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Lists the sources the catalog declares, each as the declaration that won: name,", + "kind (csv, json, database, http, generator or the connection's name), location", + "(the file, table or URL it reads) and file."}) + static final class Ls extends DeclarationVerbs.Ls { + @ParentCommand + SourceCommand source; + + @Override + RelixCommand root() { + return source.root; + } + + @Override + Noun noun() { + return Noun.SOURCE; + } + } + + /** {@code relix source edit}. */ + @Command(name = "edit", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Changes the source NAME: sets each --OPTION=VALUE, removes each --unset OPTION,", + "and with --location reads another file. Only a declaration alone in its file is", + "changed; one beside others is reported, with --editor to open it at its line."}, + exitCodeListHeading = "%nExit status:%n", + exitCodeList = { + " 0:success", + " 2:no source NAME, it shares its file with other declarations, or nothing to change", + " 5:--infer-schema cannot read the data, or the file cannot be written"}) + static final class Edit extends DeclarationVerbs.Edit { + @ParentCommand + SourceCommand source; + + @Option(names = "--location", paramLabel = "LOCATION", + description = "Read the file at LOCATION, relative to the working directory.") + String location; + + @Override + String location() { + return location; + } + + @Override + RelixCommand root() { + return source.root; + } + + @Override + Noun noun() { + return Noun.SOURCE; + } + } + + /** {@code relix source rm}. */ + @Command(name = "rm", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Removes the source NAME: the file that declares it, at the nearest level that", + "does. Refused while another declaration reads NAME, unless --force; a", + "declaration beside others in its file is reported, not touched. Says when a", + "farther declaration of NAME now shows through."}, + exitCodeListHeading = "%nExit status:%n", + exitCodeList = { + " 0:success", + " 2:no source NAME, another declaration reads it, or it shares its file"}) + static final class Rm extends DeclarationVerbs.Rm { + @ParentCommand + SourceCommand source; + + @Override + RelixCommand root() { + return source.root; + } + + @Override + Noun noun() { + return Noun.SOURCE; + } + } + + /** {@code relix source test}. */ + @Command(name = "test", + mixinStandardHelpOptions = true, + sortOptions = false, + usageHelpAutoWidth = true, + description = { + "Reads one row of each source NAME (default: every one) under the active", + "profile, and lists name, status and problem: whether its driver or connector is", + "installed, every placeholder has a value, its location can be reached, and its", + "declared schema matches what was read."}, + exitCodeListHeading = "%nExit status:%n", + exitCodeList = {" 0:every source passed", " 2:no source NAME", " 5:a source failed"}) + static final class Test extends DeclarationVerbs.Test { + @ParentCommand + SourceCommand source; + + @Override + RelixCommand root() { + return source.root; + } + + @Override + Noun noun() { + return Noun.SOURCE; + } + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/config/Profiles.java b/src/main/java/com/darkcollective/relix/cli/config/Profiles.java index 9a88985..7267878 100644 --- a/src/main/java/com/darkcollective/relix/cli/config/Profiles.java +++ b/src/main/java/com/darkcollective/relix/cli/config/Profiles.java @@ -17,6 +17,7 @@ import com.darkcollective.relix.cli.CommandFailure; import com.darkcollective.relix.cli.ExitCode; +import com.darkcollective.relix.cli.render.JsonText; import tools.jackson.core.JacksonException; import tools.jackson.core.JsonParser; import tools.jackson.core.JsonToken; @@ -24,10 +25,13 @@ import tools.jackson.core.json.JsonFactory; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.attribute.PosixFileAttributeView; import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -107,6 +111,77 @@ public static Map select(String name, List directories) { return Map.copyOf(merged); } + /** + * The profiles one file defines, for a command that changes them. + * + * @param file a profiles file, which need not exist + * @return each profile's variables, in file order, by profile name; empty when there + * is no file. The maps are the caller's to change. + * @throws CommandFailure with {@link ExitCode#ENVIRONMENT} when the file is readable by + * group or others, or is not a profiles file + */ + public static Map> load(Path file) { + if (!Files.exists(file)) { + return new LinkedHashMap<>(); + } + requirePrivate(file); + return read(file); + } + + /** + * Writes profiles to a file only its owner can read, as {@link #load} reads them. + * + * @param file the profiles file + * @param profiles each profile's variables, by profile name + * @throws CommandFailure with {@link ExitCode#ENVIRONMENT} when it cannot be written + */ + public static void save(Path file, Map> profiles) { + StringBuilder json = new StringBuilder("{"); + String profileSeparator = "\n"; + for (Map.Entry> profile : profiles.entrySet()) { + json.append(profileSeparator).append(" ").append(JsonText.quote(profile.getKey())).append(": {"); + String separator = "\n"; + for (Map.Entry variable : profile.getValue().entrySet()) { + json.append(separator).append(" ").append(JsonText.quote(variable.getKey())) + .append(": ").append(JsonText.quote(variable.getValue())); + separator = ",\n"; + } + json.append(profile.getValue().isEmpty() ? "}" : "\n }"); + profileSeparator = ",\n"; + } + json.append(profiles.isEmpty() ? "}\n" : "\n}\n"); + writePrivate(file, json.toString()); + } + + /** + * Writes a profiles file that only its owner can read, replacing any file there. The + * text goes to a temporary file made private before anything is written to it, which + * then takes the file's place, so the file is never readable by others, not even for + * a moment, and never half written. + * + * @param file the profiles file + * @param text what it holds + * @throws CommandFailure with {@link ExitCode#ENVIRONMENT} when it cannot be written + */ + public static void writePrivate(Path file, String text) { + Path dir = file.toAbsolutePath().getParent(); + try { + Files.createDirectories(dir); + Path temporary = Files.getFileStore(dir).supportsFileAttributeView(PosixFileAttributeView.class) + ? Files.createTempFile(dir, FILE_NAME, ".tmp", + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))) + : Files.createTempFile(dir, FILE_NAME, ".tmp"); + try { + Files.writeString(temporary, text, StandardCharsets.UTF_8); + Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } finally { + Files.deleteIfExists(temporary); + } + } catch (IOException e) { + throw new CommandFailure(ExitCode.ENVIRONMENT, file + ": cannot write: " + e.getMessage()); + } + } + /** * Refuses a file its group or others can read. * @@ -114,7 +189,7 @@ public static Map select(String name, List directories) { * @throws CommandFailure with {@link ExitCode#ENVIRONMENT} when it is readable by more * than its owner */ - static void requirePrivate(Path file) { + public static void requirePrivate(Path file) { PosixFileAttributeView posix = Files.getFileAttributeView(file, PosixFileAttributeView.class); if (posix == null) { return; diff --git a/src/test/java/com/darkcollective/relix/cli/command/DeclarationCommandsTest.java b/src/test/java/com/darkcollective/relix/cli/command/DeclarationCommandsTest.java new file mode 100644 index 0000000..1e5b93e --- /dev/null +++ b/src/test/java/com/darkcollective/relix/cli/command/DeclarationCommandsTest.java @@ -0,0 +1,415 @@ +/* + * Copyright 2026 Darkcollective, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.darkcollective.relix.cli.command; + +import com.darkcollective.relix.cli.Cli; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@code relix source} and {@code relix connection}, end to end through {@code Main}, + * against CSV and JSON files and an H2 database. + */ +@DisplayName("relix source and relix connection") +class DeclarationCommandsTest { + + @TempDir + Path root; + + Path home; + Path acme; + Path database; + + @BeforeEach + void tree() throws IOException, SQLException { + home = Files.createDirectories(root.resolve("home")); + acme = Files.createDirectories(home.resolve("work/acme")); + Files.createDirectories(acme.resolve("data")); + Files.writeString(acme.resolve("data/orders.csv"), "order_id,amount,placed\n1,120,2026-01-02\n2,50,2026-01-03\n"); + Files.writeString(acme.resolve("data/docs.json"), + "[{\"id\": 1, \"title\": \"a\", \"open\": true}, {\"id\": 2, \"title\": \"b\", \"open\": false}]"); + database = acme.resolve("data/warehouse"); + try (Connection connection = DriverManager.getConnection("jdbc:h2:" + database, "shop", "s3cret"); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE STOCK (SKU VARCHAR(16), ON_HAND INT)"); + statement.execute("INSERT INTO STOCK VALUES ('mug', 12), ('tee', 0)"); + } + ok(cli().run("init")); + } + + private Cli cli() { + return Cli.in(acme).home(home).piped() + .env("WAREHOUSE_URL", "jdbc:h2:" + database + ";IFEXISTS=TRUE") + .env("WAREHOUSE_USER", "shop") + .env("WAREHOUSE_PASSWORD", "s3cret"); + } + + private static Cli.Result ok(Cli.Result result) { + assertThat(result.status()).as(result.toString()).isZero(); + return result; + } + + private static Cli.Result fails(int status, Cli.Result result) { + assertThat(result.status()).as(result.toString()).isEqualTo(status); + return result; + } + + private Path catalogFile(String name) { + return acme.resolve(".relix/catalog").resolve(name); + } + + private String read(String name) throws IOException { + return Files.readString(catalogFile(name)); + } + + /** A path as tsv writes it: a backslash, Windows' separator, escaped. */ + private static String escaped(Path path) { + return path.toString().replace("\\", "\\\\"); + } + + private String trustState() { + return ok(cli().run("catalog", "trust", "--list")).out().lines() + .filter(line -> line.startsWith(escaped(acme) + "\t")) + .map(line -> line.substring(line.indexOf('\t') + 1)) + .findFirst().orElse("untrusted"); + } + + private void addOrders() { + ok(cli().run("source", "add", "Orders", "csv", "./data/orders.csv", "--header", "--infer-schema")); + } + + private void addWarehouse() { + ok(cli().run("connection", "add", "warehouse", "database", "--url=${WAREHOUSE_URL}", + "--user=${WAREHOUSE_USER}", "--password=${WAREHOUSE_PASSWORD}")); + } + + @Nested + @DisplayName("add") + class Add { + + @Test + @DisplayName("writes a csv source to a file of its own, its path relative to the file, its schema inferred") + void csv() throws IOException { + var result = ok(cli().run("source", "add", "Orders", "csv", "./data/orders.csv", "--header", + "--infer-schema")); + + assertThat(result.out()).isEmpty(); + assertThat(read("00-source-Orders.relix")).isEqualTo("source Orders from csv(\"../../data/orders.csv\")" + + " { header: true, schema: { order_id: number, amount: number, placed: date } };\n"); + assertThat(ok(cli().run("-e", "PROJECT order_id (SELECT amount > 100 (Orders))")).out()) + .isEqualTo("order_id\n1\n"); + } + + @Test + @DisplayName("writes a json source, which reads its rows as they come and so takes no --infer-schema") + void json() throws IOException { + ok(cli().run("source", "add", "Docs", "json", "data/docs.json")); + + assertThat(read("00-source-Docs.relix")).isEqualTo("source Docs from json(\"../../data/docs.json\") { };\n"); + assertThat(ok(cli().run("-e", "PROJECT title (SELECT id = 2 (Docs))")).out()).isEqualTo("title\nb\n"); + assertThat(fails(2, cli().run("source", "add", "Other", "json", "data/docs.json", "--infer-schema")).err()) + .contains("a json source declares no schema"); + } + + @Test + @DisplayName("writes a connection and a source over its table, with secrets as placeholders") + void jdbc() throws IOException { + addWarehouse(); + ok(cli().run("source", "add", "Stock", "warehouse", "--table=STOCK", "--infer-schema")); + + assertThat(read("00-connection-warehouse.relix")).isEqualTo("connection warehouse from jdbc" + + " { password: \"${WAREHOUSE_PASSWORD}\", url: \"${WAREHOUSE_URL}\", user: \"${WAREHOUSE_USER}\" };\n"); + assertThat(read("00-source-Stock.relix")).isEqualTo("source Stock from warehouse" + + " { table: \"STOCK\", schema: { SKU: string, ON_HAND: number } };\n"); + assertThat(ok(cli().run("-e", "PROJECT SKU (SELECT ON_HAND = 0 (Stock))")).out()).isEqualTo("SKU\ntee\n"); + } + + @Test + @DisplayName("keeps a trusted project trusted, and leaves the run with no trust warning") + void trust() { + addOrders(); + addWarehouse(); + + assertThat(trustState()).isEqualTo("trusted"); + assertThat(cli().run("-e", "Orders").err()).doesNotContain("trust"); + } + + @Test + @DisplayName("refuses a missing required value with exit 2, naming the flag") + void missing() { + assertThat(fails(2, cli().run("source", "add", "Orders", "csv")).err()).contains("LOCATION is required"); + assertThat(fails(2, cli().run("source", "add", "T", "database", "--table=T", "--schema={ a: NUMBER }")) + .err()).contains("--url is required"); + addWarehouse(); + assertThat(fails(2, cli().run("source", "add", "Stock", "warehouse", "--table=STOCK")).err()) + .contains("--schema is required").contains("--infer-schema"); + assertThat(fails(2, cli().run("connection", "add", "w", "database")).err()).contains("--url is required"); + } + + @Test + @DisplayName("refuses a secret given as its value, which it never writes") + void secret() throws IOException { + var result = fails(2, cli().run("connection", "add", "w", "database", "--url=jdbc:h2:mem:x", + "--password=hunter2")); + + assertThat(result.err()).contains("--password: a secret is written as a ${NAME} placeholder"); + assertThat(fails(2, cli().run("connection", "add", "w", "database", + "--url=jdbc:postgresql://db/app?user=a&password=hunter2")).err()).contains("--url"); + try (var files = Files.list(acme.resolve(".relix/catalog"))) { + assertThat(files).isEmpty(); + } + } + + @Test + @DisplayName("refuses a kind that is neither a source kind nor a declared connection") + void unknownKind() { + assertThat(fails(2, cli().run("source", "add", "Stock", "warehouse", "--table=STOCK")).err()) + .contains("'warehouse' is neither a source kind").contains("relix connection add"); + } + + @Test + @DisplayName("refuses a name the same level declares already, and a flag with no =") + void duplicate() { + addOrders(); + + assertThat(fails(2, cli().run("source", "add", "Orders", "csv", "data/orders.csv")).err()) + .contains("Orders is already declared here").contains("relix source edit Orders"); + assertThat(fails(2, cli().run("source", "add", "Other", "csv", "data/orders.csv", "--header", "true")) + .err()).contains("expected --OPTION=VALUE"); + } + + @Test + @DisplayName("allows a name an outer level declares, warning of the file it shadows") + void overlay() { + ok(cli().run("source", "add", "--global", "Orders", "csv", "./data/orders.csv", "--header", "--infer-schema")); + Path outer = home.resolve(".relix/catalog/00-source-Orders.relix"); + assertThat(outer).exists(); + + var result = ok(cli().run("source", "add", "Orders", "csv", "./data/orders.csv", "--infer-schema")); + + assertThat(result.err()).contains("warning: Orders is declared at " + outer + ":1 too"); + } + + @Test + @DisplayName("refuses to write where there is no project, pointing to relix init and --global") + void noProject() throws IOException { + Path elsewhere = Files.createDirectories(home.resolve("elsewhere")); + + var result = fails(2, Cli.in(elsewhere).home(home).piped() + .run("source", "add", "T", "csv", "../work/acme/data/orders.csv")); + assertThat(result.err()).contains("relix init").contains("--global"); + } + } + + @Nested + @DisplayName("ls") + class Ls { + + @Test + @DisplayName("lists each source and connection: name, kind, location and file") + void both() { + addOrders(); + addWarehouse(); + ok(cli().run("source", "add", "Stock", "warehouse", "--table=STOCK", "--infer-schema")); + + assertThat(ok(cli().run("source", "ls")).out()).isEqualTo("name\tkind\tlocation\tfile\n" + + "Orders\tcsv\t../../data/orders.csv\t" + escaped(catalogFile("00-source-Orders.relix")) + "\n" + + "Stock\twarehouse\tSTOCK\t" + escaped(catalogFile("00-source-Stock.relix")) + "\n"); + assertThat(ok(cli().run("connection", "ls")).out()).isEqualTo("name\tkind\tlocation\tfile\n" + + "warehouse\tjdbc\t${WAREHOUSE_URL}\t" + escaped(catalogFile("00-connection-warehouse.relix")) + "\n"); + } + } + + @Nested + @DisplayName("test") + class TestVerb { + + @Test + @DisplayName("passes every source and connection that can be read, exiting 0") + void passes() { + addOrders(); + addWarehouse(); + ok(cli().run("source", "add", "Stock", "warehouse", "--table=STOCK", "--infer-schema")); + ok(cli().run("source", "add", "Docs", "json", "data/docs.json")); + + assertThat(ok(cli().run("source", "test")).out()).isEqualTo( + "name\tstatus\tproblem\nDocs\tok\t\nOrders\tok\t\nStock\tok\t\n"); + assertThat(ok(cli().run("connection", "test")).out()).isEqualTo("name\tstatus\tproblem\nwarehouse\tok\t\n"); + } + + @Test + @DisplayName("fails a source whose file is gone, or whose schema does not match, exiting 5") + void failing() throws IOException { + addOrders(); + ok(cli().run("source", "add", "Gone", "csv", "data/gone.csv", "--schema={ a: NUMBER }")); + ok(cli().run("source", "add", "Wrong", "csv", "data/orders.csv", + "--schema={ order_id: NUMBER, missing: STRING }")); + ok(cli().run("source", "add", "Typed", "csv", "data/orders.csv", + "--schema={ order_id: NUMBER, placed: NUMBER }")); + + var result = DeclarationCommandsTest.fails(5, cli().run("source", "test")); + + assertThat(result.out()).startsWith("name\tstatus\tproblem\n") + .contains("Gone\tfailed\tCannot read CSV file") + .contains("Orders\tok\t\n") + .contains("Wrong\tfailed\t").contains("'missing'") + .contains("Typed\tfailed\t"); + assertThat(ok(cli().run("source", "test", "Orders")).out()).isEqualTo("name\tstatus\tproblem\nOrders\tok\t\n"); + } + + @Test + @DisplayName("fails a connection that cannot be reached, or whose placeholder has no value") + void connectionFails() { + ok(cli().run("connection", "add", "down", "database", "--url=jdbc:h2:tcp://127.0.0.1:1/nothing")); + addWarehouse(); + + var result = DeclarationCommandsTest.fails(5, Cli.in(acme).home(home).piped() + .run("connection", "test")); + + assertThat(result.out()).contains("down\tfailed\t").contains("Connection") + .contains("warehouse\tfailed\t${WAREHOUSE_PASSWORD} has no value"); + } + } + + @Nested + @DisplayName("edit") + class Edit { + + @Test + @DisplayName("sets and removes options, and moves a file source, keeping the project trusted") + void flags() throws IOException { + addOrders(); + Files.copy(acme.resolve("data/orders.csv"), acme.resolve("data/orders-2026.csv")); + + ok(cli().run("source", "edit", "Orders", "--unset=header", "--location=data/orders-2026.csv")); + + assertThat(read("00-source-Orders.relix")).isEqualTo("source Orders from csv(\"../../data/orders-2026.csv\")" + + " { header: true, schema: { order_id: number, amount: number, placed: date } };\n"); + ok(cli().run("connection", "add", "w", "database", "--url=jdbc:h2:mem:a")); + ok(cli().run("connection", "edit", "w", "--url=jdbc:h2:mem:b", "--dialect=h2")); + assertThat(read("00-connection-w.relix")).isEqualTo( + "connection w from jdbc { dialect: \"h2\", url: \"jdbc:h2:mem:b\" };\n"); + assertThat(trustState()).isEqualTo("trusted"); + } + + @Test + @DisplayName("refuses nothing to change, and an option it does not set") + void refusals() { + addOrders(); + + assertThat(fails(2, cli().run("source", "edit", "Orders")).err()).contains("nothing to change"); + assertThat(fails(2, cli().run("source", "edit", "Orders", "--unset=table")).err()).contains("sets no table"); + assertThat(fails(2, cli().run("source", "edit", "Nope", "--header")).err()) + .contains("the catalog declares no source Nope"); + } + + @Test + @DisplayName("reports a declaration beside others in a hand-written file, with its line, and suggests --editor") + void handWritten() throws IOException { + Files.writeString(catalogFile("10-mixed.relix"), "-- by hand\nT := [\n| v |\n|---|\n| 1 |\n];\n\n" + + "source Mixed from csv(\"../../data/orders.csv\") { header: true };\n"); + ok(cli().run("catalog", "trust")); + + var edit = fails(2, cli().run("source", "edit", "Mixed", "--header=false")); + assertThat(edit.err()).contains(catalogFile("10-mixed.relix") + ":8 declares Mixed beside other declarations") + .contains("--editor"); + var rm = fails(2, cli().run("source", "rm", "Mixed")); + assertThat(rm.err()).contains(catalogFile("10-mixed.relix") + ":8"); + assertThat(read("10-mixed.relix")).contains("source Mixed"); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + @DisplayName("--editor opens $EDITOR at the declaration's line, and keeps the project trusted") + void editor() throws IOException { + Files.writeString(catalogFile("10-mixed.relix"), "T := [\n| v |\n|---|\n| 1 |\n];\n" + + "source Mixed from csv(\"../../data/orders.csv\") { header: true };\n"); + ok(cli().run("catalog", "trust")); + Path editor = root.resolve("editor.sh"); + Path args = root.resolve("args.txt"); + Files.writeString(editor, "#!/bin/sh\necho \"$@\" > '" + args + "'\necho '-- edited' >> \"$2\"\n"); + Files.setPosixFilePermissions(editor, PosixFilePermissions.fromString("rwx------")); + + ok(cli().env("EDITOR", editor.toString()).run("source", "edit", "Mixed", "--editor")); + + assertThat(Files.readString(args).strip()).isEqualTo("+6 " + catalogFile("10-mixed.relix")); + assertThat(read("10-mixed.relix")).endsWith("-- edited\n"); + assertThat(trustState()).isEqualTo("trusted"); + } + } + + @Nested + @DisplayName("rm") + class Rm { + + @Test + @DisplayName("refuses a declaration another reads, listing them, unless --force") + void readers() throws IOException { + addOrders(); + Files.writeString(catalogFile("10-views.relix"), "Big := { SELECT amount > 100 (Orders) };\n"); + ok(cli().run("catalog", "trust")); + + var refused = fails(2, cli().run("source", "rm", "Orders")); + assertThat(refused.err()).contains("Orders is read by").contains("Big (" + catalogFile("10-views.relix") + ":1)"); + assertThat(catalogFile("00-source-Orders.relix")).exists(); + + ok(cli().run("source", "rm", "Orders", "--force")); + assertThat(catalogFile("00-source-Orders.relix")).doesNotExist(); + assertThat(trustState()).isEqualTo("trusted"); + } + + @Test + @DisplayName("refuses a connection a source reads") + void connection() { + addWarehouse(); + ok(cli().run("source", "add", "Stock", "warehouse", "--table=STOCK", "--infer-schema")); + + assertThat(fails(2, cli().run("connection", "rm", "warehouse")).err()).contains("Stock ("); + ok(cli().run("source", "rm", "Stock")); + ok(cli().run("connection", "rm", "warehouse")); + assertThat(ok(cli().run("connection", "ls")).out()).isEqualTo("name\tkind\tlocation\tfile\n"); + } + + @Test + @DisplayName("removes only the nearest declaration, and says when a farther one now shows through") + void showsThrough() { + ok(cli().run("source", "add", "--global", "Orders", "csv", "./data/orders.csv", "--infer-schema")); + addOrders(); + + var result = ok(cli().run("source", "rm", "Orders")); + + Path outer = home.resolve(".relix/catalog/00-source-Orders.relix"); + assertThat(result.err()).contains("Orders now comes from " + outer + ":1"); + assertThat(outer).exists(); + } + } +} diff --git a/src/test/java/com/darkcollective/relix/cli/command/InitTest.java b/src/test/java/com/darkcollective/relix/cli/command/InitTest.java new file mode 100644 index 0000000..a143985 --- /dev/null +++ b/src/test/java/com/darkcollective/relix/cli/command/InitTest.java @@ -0,0 +1,157 @@ +/* + * Copyright 2026 Darkcollective, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.darkcollective.relix.cli.command; + +import com.darkcollective.relix.cli.Cli; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@code relix init}, end to end through {@code Main}. + */ +@DisplayName("relix init") +class InitTest { + + @TempDir + Path root; + + Path home; + Path acme; + + @BeforeEach + void tree() throws IOException { + home = Files.createDirectories(root.resolve("home")); + acme = Files.createDirectories(home.resolve("work/acme")); + } + + private Cli cli() { + return Cli.in(acme).home(home).piped(); + } + + private static Stream tree(Path dir) throws IOException { + try (Stream walk = Files.walk(dir)) { + return walk.map(p -> dir.relativize(p).toString().replace('\\', '/')).sorted().toList().stream(); + } + } + + @Test + @DisplayName("makes catalog/, a private empty profiles.json and the .gitignore, and nothing else") + void plain() throws IOException { + var result = cli().run("init"); + + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.out()).isEmpty(); + Path level = acme.resolve(".relix"); + assertThat(tree(level)).containsExactly("", ".gitignore", "catalog", "profiles.json"); + assertThat(Files.readString(level.resolve("profiles.json"))).isEqualTo("{}\n"); + assertThat(Files.readString(level.resolve(".gitignore"))).isEqualTo("profiles.json\n"); + assertThat(result.err()).contains("created " + level).contains("trusting " + level); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + @DisplayName("makes profiles.json readable by its owner alone") + void privateProfiles() throws IOException { + cli().run("init"); + + assertThat(PosixFilePermissions.toString( + Files.getPosixFilePermissions(acme.resolve(".relix/profiles.json")))).isEqualTo("rw-------"); + } + + @Test + @DisplayName("writes relixrc with -o and -P, defining the profile it names, and root with --root") + void everyFlag() throws IOException { + Path other = home.resolve("work/other"); + var result = cli().run("init", "../other", "--root", "--profile=staging", "--output=csv"); + + assertThat(result.status()).as(result.toString()).isZero(); + Path level = other.resolve(".relix"); + assertThat(tree(level)).containsExactly("", ".gitignore", "catalog", "profiles.json", "relixrc", "root"); + assertThat(Files.readString(level.resolve("relixrc"))).isEqualTo("output = csv\nprofile = staging\n"); + assertThat(Files.readString(level.resolve("profiles.json"))).isEqualTo("{\n \"staging\": {}\n}\n"); + assertThat(Files.exists(acme.resolve(".relix"))).isFalse(); + } + + @Test + @DisplayName("leaves the project trusted: the next run there says nothing of trust, and uses relixrc") + void trusted() { + cli().run("init", "-o", "csv", "-P", "staging"); + + var result = cli().run("-e", "T := [\n| v |\n|---|\n| 1 |\n];\nquery T;"); + + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.err()).doesNotContain("trust"); + assertThat(result.out()).isEqualTo("v\n1\n"); + } + + @Test + @DisplayName("refuses a directory that has a .relix/ already, exiting 2 and touching nothing") + void exists() throws IOException { + cli().run("init"); + Files.writeString(acme.resolve(".relix/profiles.json"), "{ \"dev\": {} }\n"); + + var result = cli().run("init", "--root", "-o", "csv"); + + assertThat(result.status()).as(result.toString()).isEqualTo(2); + assertThat(result.err()).contains(acme.resolve(".relix") + " already exists"); + assertThat(tree(acme.resolve(".relix"))).containsExactly("", ".gitignore", "catalog", "profiles.json"); + assertThat(Files.readString(acme.resolve(".relix/profiles.json"))).isEqualTo("{ \"dev\": {} }\n"); + } + + @Test + @DisplayName("refuses an unknown output format before writing anything") + void badFormat() { + var result = cli().run("init", "-o", "yaml"); + + assertThat(result.status()).as(result.toString()).isEqualTo(2); + assertThat(result.err()).contains("unknown output format: 'yaml'"); + assertThat(Files.exists(acme.resolve(".relix"))).isFalse(); + } + + @Test + @DisplayName("warns, outside $HOME, that only the working directory's .relix/ is read there") + void outsideHome() throws IOException { + Path elsewhere = Files.createDirectories(root.resolve("srv/app")); + + var result = Cli.in(elsewhere).home(home).piped().run("init"); + + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.err()).contains("warning: " + elsewhere + " is outside " + home); + assertThat(Files.isDirectory(elsewhere.resolve(".relix/catalog"))).isTrue(); + } + + @Test + @DisplayName("in $HOME makes the user's own ~/.relix, which needs no trust") + void userLevel() throws IOException { + var result = Cli.in(home).home(home).piped().run("init"); + + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.err()).doesNotContain("trusting"); + assertThat(Files.exists(home.resolve(".relix/trusted"))).isFalse(); + } +} diff --git a/src/test/java/com/darkcollective/relix/cli/command/ProfilesCommandTest.java b/src/test/java/com/darkcollective/relix/cli/command/ProfilesCommandTest.java new file mode 100644 index 0000000..c4eda50 --- /dev/null +++ b/src/test/java/com/darkcollective/relix/cli/command/ProfilesCommandTest.java @@ -0,0 +1,314 @@ +/* + * Copyright 2026 Darkcollective, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.darkcollective.relix.cli.command; + +import com.darkcollective.relix.cli.Cli; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * {@code relix profiles}, end to end through {@code Main}: its verbs at project and user + * scope, and the trust rule every command that writes under {@code .relix/} follows. + */ +@DisplayName("relix profiles") +class ProfilesCommandTest { + + private static final String SECRET = "hunter2-do-not-print"; + + @TempDir + Path root; + + Path home; + Path acme; + + @BeforeEach + void tree() throws IOException { + home = Files.createDirectories(root.resolve("home")); + acme = Files.createDirectories(home.resolve("work/acme")); + } + + private Cli cli() { + return Cli.in(acme).home(home).piped(); + } + + private Cli.Result ok(Cli.Result result) { + assertThat(result.status()).as(result.toString()).isZero(); + return result; + } + + private void init() { + ok(cli().run("init")); + } + + private Path projectFile() { + return acme.resolve(".relix/profiles.json"); + } + + /** A path as tsv writes it: a backslash, Windows' separator, escaped. */ + private static String escaped(Path path) { + return path.toString().replace("\\", "\\\\"); + } + + @Nested + @DisplayName("in a project") + class Project { + + @BeforeEach + void project() { + init(); + } + + @Test + @DisplayName("set reads the value from standard input and writes it to the project's file alone") + void set() throws IOException { + var result = ok(cli().stdin(SECRET + "\n").run("profiles", "set", "staging", "WAREHOUSE_PASSWORD")); + + assertThat(result.out()).isEmpty(); + assertThat(result.err()).doesNotContain(SECRET) + .contains("set WAREHOUSE_PASSWORD in profile staging in " + projectFile()); + assertThat(Files.readString(projectFile())).isEqualTo( + "{\n \"staging\": {\n \"WAREHOUSE_PASSWORD\": \"" + SECRET + "\"\n }\n}\n"); + assertThat(Files.exists(home.resolve(".relix/profiles.json"))).isFalse(); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + @DisplayName("set leaves the file readable by its owner alone") + void privateFile() throws IOException { + ok(cli().stdin(SECRET).run("profiles", "set", "staging", "PW")); + + assertThat(PosixFilePermissions.toString(Files.getPosixFilePermissions(projectFile()))) + .isEqualTo("rw-------"); + } + + @Test + @DisplayName("ls lists profile, variable and file, never a value; get prints the value") + void lsAndGet() { + ok(cli().stdin(SECRET).run("profiles", "set", "staging", "WAREHOUSE_PASSWORD")); + ok(cli().stdin("jdbc:h2:mem:x").run("profiles", "set", "staging", "WAREHOUSE_URL")); + + var ls = ok(cli().run("profiles", "ls")); + assertThat(ls.out()).isEqualTo("profile\tvariable\tfile\n" + + "staging\tWAREHOUSE_PASSWORD\t" + escaped(projectFile()) + "\n" + + "staging\tWAREHOUSE_URL\t" + escaped(projectFile()) + "\n"); + assertThat(ok(cli().run("profiles")).out()).isEqualTo(ls.out()); + + assertThat(ok(cli().run("profiles", "get", "staging", "WAREHOUSE_PASSWORD")).out()) + .isEqualTo(SECRET + "\n"); + } + + @Test + @DisplayName("set replaces a value, and the value a run resolves is the new one") + void replace() { + ok(cli().stdin("one").run("profiles", "set", "dev", "V")); + ok(cli().stdin("two").run("profiles", "set", "dev", "V")); + + var run = ok(cli().run("-P", "dev", "-e", "T := [\n| v |\n|---|\n| 1 |\n];\nquery T;")); + assertThat(ok(cli().run("profiles", "get", "dev", "V")).out()).isEqualTo("two\n"); + assertThat(run.err()).doesNotContain("trust"); + } + + @Test + @DisplayName("rm removes a variable, or a whole profile, and refuses one the file does not set") + void rm() throws IOException { + ok(cli().stdin("a").run("profiles", "set", "dev", "A")); + ok(cli().stdin("b").run("profiles", "set", "dev", "B")); + + ok(cli().run("profiles", "rm", "dev", "A")); + assertThat(Files.readString(projectFile())).isEqualTo("{\n \"dev\": {\n \"B\": \"b\"\n }\n}\n"); + + var missing = cli().run("profiles", "rm", "dev", "A"); + assertThat(missing.status()).as(missing.toString()).isEqualTo(2); + assertThat(missing.err()).contains("does not set A"); + + ok(cli().run("profiles", "rm", "dev")); + assertThat(Files.readString(projectFile())).isEqualTo("{}\n"); + } + + @Test + @DisplayName("get of a variable the profile does not set exits 2") + void getMissing() { + ok(cli().stdin("a").run("profiles", "set", "dev", "A")); + + var result = cli().run("profiles", "get", "dev", "B"); + + assertThat(result.status()).as(result.toString()).isEqualTo(2); + assertThat(result.out()).isEmpty(); + } + + @Test + @DisplayName("set refuses an empty value and a name that is not a variable, exiting 2") + void refusals() { + var empty = cli().stdin("\n").run("profiles", "set", "dev", "A"); + assertThat(empty.status()).as(empty.toString()).isEqualTo(2); + assertThat(empty.err()).contains("no value"); + + var name = cli().stdin("x").run("profiles", "set", "dev", "not-a-name"); + assertThat(name.status()).as(name.toString()).isEqualTo(2); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + @DisplayName("set refuses a file readable by others, exiting 5, and leaves it as it was") + void loose() throws IOException { + Files.setPosixFilePermissions(projectFile(), PosixFilePermissions.fromString("rw-r--r--")); + + var result = cli().stdin(SECRET).run("profiles", "set", "dev", "PW"); + + assertThat(result.status()).as(result.toString()).isEqualTo(5); + assertThat(result.err()).contains("chmod 600"); + assertThat(Files.readString(projectFile())).isEqualTo("{}\n"); + } + + @Test + @DisplayName("warns when git tracks the file") + void tracked() throws IOException, InterruptedException { + String path = System.getenv("PATH"); + assumeTrue(path != null && git(acme, "init", "-q") == 0, "git is installed"); + assumeTrue(git(acme, "add", "-f", ".relix/profiles.json") == 0, "git can add a file"); + + var result = ok(cli().env("PATH", path).stdin(SECRET).run("profiles", "set", "dev", "PW")); + + assertThat(result.err()).contains("is tracked by git").contains("git rm --cached"); + } + + @Test + @DisplayName("says nothing of git when the file is not tracked") + void untracked() throws IOException, InterruptedException { + String path = System.getenv("PATH"); + assumeTrue(path != null && git(acme, "init", "-q") == 0, "git is installed"); + + var result = ok(cli().env("PATH", path).stdin(SECRET).run("profiles", "set", "dev", "PW")); + + assertThat(result.err()).doesNotContain("git"); + } + } + + @Nested + @DisplayName("with --user") + class User { + + @Test + @DisplayName("every verb works on ~/.relix/profiles.json, with or without a project") + void everyVerb() throws IOException { + Path file = home.resolve(".relix/profiles.json"); + + ok(cli().stdin(SECRET).run("profiles", "set", "--user", "dev", "PW")); + assertThat(Files.readString(file)).contains(SECRET); + assertThat(ok(cli().run("profiles", "ls", "--user")).out()) + .isEqualTo("profile\tvariable\tfile\ndev\tPW\t" + escaped(file) + "\n"); + assertThat(ok(cli().run("profiles", "get", "--user", "dev", "PW")).out()).isEqualTo(SECRET + "\n"); + ok(cli().run("profiles", "rm", "--user", "dev", "PW")); + assertThat(Files.readString(file)).isEqualTo("{\n \"dev\": {}\n}\n"); + } + + @Test + @DisplayName("is what set needs where there is no project, which it otherwise refuses with exit 2") + void noProject() { + var result = cli().stdin(SECRET).run("profiles", "set", "dev", "PW"); + + assertThat(result.status()).as(result.toString()).isEqualTo(2); + assertThat(result.err()).contains("relix init").contains("--user"); + } + + @Test + @DisplayName("a project's value beats the user's for get, as it does for a run") + void nearestWins() { + init(); + ok(cli().stdin("user").run("profiles", "set", "--user", "dev", "V")); + ok(cli().stdin("project").run("profiles", "set", "dev", "V")); + + assertThat(ok(cli().run("profiles", "get", "dev", "V")).out()).isEqualTo("project\n"); + assertThat(ok(cli().run("profiles", "get", "--user", "dev", "V")).out()).isEqualTo("user\n"); + } + } + + @Nested + @DisplayName("keeps a project's trust as it was") + class Trust { + + private String state() { + var list = ok(cli().run("catalog", "trust", "--list")); + return list.out().lines() + .filter(line -> line.startsWith(escaped(acme) + "\t")) + .map(line -> line.substring(line.indexOf('\t') + 1)) + .findFirst().orElse("untrusted"); + } + + @Test + @DisplayName("a trusted, unchanged project stays trusted across set and rm") + void staysTrusted() { + init(); + + var set = ok(cli().stdin(SECRET).run("profiles", "set", "dev", "PW")); + assertThat(set.err()).contains(acme.resolve(".relix") + " is still trusted"); + assertThat(state()).isEqualTo("trusted"); + + ok(cli().run("profiles", "rm", "dev")); + assertThat(state()).isEqualTo("trusted"); + } + + @Test + @DisplayName("a project changed by someone else since it was trusted stays untrusted") + void changedStaysChanged() throws IOException { + init(); + Files.writeString(acme.resolve(".relix/catalog/00-hook.relix"), "T := [\n| v |\n|---|\n| 1 |\n];\n"); + + var set = ok(cli().stdin(SECRET).run("profiles", "set", "dev", "PW")); + + assertThat(set.err()).contains("is not trusted now").contains("relix catalog trust"); + assertThat(state()).isEqualTo("changed"); + } + + @Test + @DisplayName("a project never trusted stays untrusted") + void untrustedStaysUntrusted() throws IOException { + Files.createDirectories(acme.resolve(".relix/catalog")); + + var set = ok(cli().stdin(SECRET).run("profiles", "set", "dev", "PW")); + + assertThat(set.err()).contains("is not trusted now"); + assertThat(state()).isEqualTo("untrusted"); + } + } + + private static int git(Path dir, String... args) throws IOException, InterruptedException { + String[] command = new String[args.length + 1]; + command[0] = "git"; + System.arraycopy(args, 0, command, 1, args.length); + try { + Process process = new ProcessBuilder(command).directory(dir.toFile()) + .redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.DISCARD).start(); + return process.waitFor(30, TimeUnit.SECONDS) ? process.exitValue() : -1; + } catch (IOException e) { + return -1; + } + } +}