diff --git a/README.md b/README.md index b4babd8..9c68e85 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ during development to quickly access various subsystems in the Crazyflie and sup * Turn the platform on/off or put it to sleep/wake it up * Run stability tests with the Crazyflie * High-level commander (takeoff, land, go-to, trajectories) -* Lighthouse positioning system configuration (read/write base station geometry and calibration) +* Lighthouse positioning system configuration (read/write base station geometry and calibration, store and share configurations, write them to a whole swarm) * Loco positioning system configuration (read/write anchor positions, cfclient-compatible YAML) * AI-agent and scripting friendly (CSV output, classified exit codes, non-interactive mode, command timeouts) @@ -87,7 +87,7 @@ Commands: lh Lighthouse positioning system configuration deck Decks attached to the Crazyflie swarm Swarms of Crazyflies: store them and run commands on them - auth Your account for sharing swarms: sign in, sign out + auth Your account for sharing swarms and lighthouse configs: sign in, sign out completions Generate a shell completion script (printed to stdout) help Print this message or the help of the given subcommand(s) @@ -111,7 +111,7 @@ Exit codes: 30 invalid value (range, type, malformed input) 40 --timeout expired on a bounded command 50 a swarm command failed on some of the Crazyflies - 60 a check found differences (lh config check) + 60 a check found differences (lh config check, swarm lh check) ``` To use the CLI you must first select which URI to use, this is done by scanning for available Crazyflies @@ -245,7 +245,7 @@ prompt) a few flags make the output predictable: `platform info`, `swarm config list`/`show`, `swarm scan`, `swarm platform info`, `swarm param get`, `swarm log print`, `swarm deck list`, `swarm debug assert`, `swarm bootload info`, - `lh config display`, `lh config check`). Other + `swarm lh check`, `lh config list`/`display`/`check`). Other commands ignore the flag. Exit codes: @@ -260,7 +260,7 @@ Exit codes: | 30 | Invalid value (range, type, malformed input) | | 40 | `--timeout` expired on a bounded command | | 50 | A swarm command failed on some of the Crazyflies | -| 60 | A check found differences (`lh config check`) | +| 60 | A check found differences (`lh config check`, `swarm lh check`) | Worked example — read one parameter into a shell variable: diff --git a/build.rs b/build.rs index 3387567..f72283a 100644 --- a/build.rs +++ b/build.rs @@ -99,6 +99,23 @@ fn main() { "or lists them when non-interactive):_default", "or lists them when non-interactive):_cfcli_swarm_configs", ); + // Lighthouse config IDs: `--config`, `import --id` and the positional + // ID of the `lh config` commands and `swarm config lh`. + s = s.replace(":CONFIG:_default", ":CONFIG:_cfcli_lh_configs"); + for help in [ + "Stored lighthouse config to display", + "Stored lighthouse config to write", + "Stored lighthouse config to compare with", + "Stored lighthouse config to export", + "Lighthouse config to name", + "Lighthouse config ID (prompts for one if omitted)", + "The lighthouse config to move", + "Only this shared lighthouse config (/)", + "Lighthouse config ID, picked from a list if omitted (a local swarm names one on this computer, a shared swarm one in its organization)", + "/ shares it on the server", + ] { + s = s.replace(&format!("{}:_default", help), &format!("{}:_cfcli_lh_configs", help)); + } // Crazyflie names: `--from-swarm` takes one, `--cf`/`--exclude` a // comma-separated list. `::CF` first, it also contains `:CF`. s = s.replace("::CF:_default", "::CF:_cfcli_swarm_units"); diff --git a/completions/addendum.bash b/completions/addendum.bash index 4fa4114..e03146e 100644 --- a/completions/addendum.bash +++ b/completions/addendum.bash @@ -46,6 +46,9 @@ _cfcli_dynamic() { *" swarm config select "*|*" swarm config delete "*|*" swarm config show "*|*" swarm config export "*) kind="swarm-configs" ;; *" swarm config remove "*|*" swarm config rename "*) kind="swarm-units" ;; + *" swarm config lh "*|*" lh config display "*|*" lh config save "*|*" lh config write "*|\ + *" lh config check "*|*" lh config export "*|*" lh config name "*|*" lh config delete "*|*" lh config move "*|\ + *" lh config pull "*|*" lh config push "*) kind="lh-configs" ;; esac # Option values: `--targets x,y` (plain list) / `--bin t=f` (key=value), @@ -54,14 +57,16 @@ _cfcli_dynamic() { --bin) kind="flash-targets"; suffix="="; nospace=1 ;; --targets) kind="flash-targets" ;; --swarm) kind="swarm-configs" ;; + --config) kind="lh-configs" ;; --cf|--exclude) kind="swarm-units"; nospace=1 ;; --from-swarm) kind="swarm-units" ;; # Values that are a new name, a file or free text. - -o|--output|--id|--name|--description) kind="" ;; + -o|--output|-i|--input|--id|--name|--description) kind="" ;; =) case "${COMP_WORDS[COMP_CWORD-2]}" in --bin) kind="flash-targets"; suffix="="; nospace=1 ;; --targets) kind="flash-targets" ;; --swarm) kind="swarm-configs" ;; + --config) kind="lh-configs" ;; --cf|--exclude) kind="swarm-units"; nospace=1 ;; --from-swarm) kind="swarm-units" ;; esac ;; diff --git a/completions/addendum.ps1 b/completions/addendum.ps1 index f181be2..b25559b 100644 --- a/completions/addendum.ps1 +++ b/completions/addendum.ps1 @@ -15,6 +15,8 @@ 'cfcli;(swarm;)?log;print$' { $cfcliDynKind = 'log-names' } 'cfcli;swarm;config;(select|delete|show|export)$' { $cfcliDynKind = 'swarm-configs' } 'cfcli;swarm;config;(remove|rename)$' { $cfcliDynKind = 'swarm-units' } + 'cfcli;swarm;config;lh$' { $cfcliDynKind = 'lh-configs' } + 'cfcli;lh;config;(display|save|write|check|export|name|delete|move|pull|push)$' { $cfcliDynKind = 'lh-configs' } } # Option values: `--targets x,y` (plain list) / `--bin t=f` (key=value), # space-separated form. @@ -30,9 +32,10 @@ if ($cfcliPrev -eq '--targets') { $cfcliDynKind = 'flash-targets' } if ($cfcliPrev -eq '--bin') { $cfcliDynKind = 'flash-targets'; $cfcliSuffix = '=' } if ($cfcliPrev -eq '--swarm') { $cfcliDynKind = 'swarm-configs' } + if ($cfcliPrev -eq '--config') { $cfcliDynKind = 'lh-configs' } if ($cfcliPrev -in '--cf', '--exclude', '--from-swarm') { $cfcliDynKind = 'swarm-units' } # Values that are a new name, a file or free text. - if ($cfcliPrev -in '-o', '--output', '--id', '--name', '--description') { $cfcliDynKind = '' } + if ($cfcliPrev -in '-o', '--output', '-i', '--input', '--id', '--name', '--description') { $cfcliDynKind = '' } if ($cfcliDynKind -ne '') { # `cfcli __complete` handles comma-separated lists, returning fully diff --git a/completions/addendum.zsh b/completions/addendum.zsh index 2ff4ee2..50f34df 100644 --- a/completions/addendum.zsh +++ b/completions/addendum.zsh @@ -27,6 +27,7 @@ _cfcli_config_set() { _cfcli__dyn config-keys '=' } _cfcli_log_names() { _cfcli__dyn log-names '' } _cfcli_flash_targets() { _cfcli__dyn flash-targets } _cfcli_swarm_configs() { _cfcli__dyn swarm-configs } +_cfcli_lh_configs() { _cfcli__dyn lh-configs } _cfcli_swarm_units() { _cfcli__dyn swarm-units } _cfcli_swarm_unit_list() { _cfcli__dyn swarm-units '' } # `--bin` is a comma-separated list of `target=file`. In the current segment diff --git a/docs/auth.md b/docs/auth.md index 2c63463..cafa941 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -1,7 +1,8 @@ # Signing in -The `auth` command signs cfcli in to the server that shares swarms between -cfcli, Swarmkeeper and the people you fly with. Signing in is optional: +The `auth` command signs cfcli in to the server that shares swarms and +lighthouse configurations between cfcli, Swarmkeeper and the people you fly +with. Signing in is optional: everything else in cfcli works without it. ```text @@ -44,8 +45,8 @@ cfcli auth status ``` Prints the server, your name, the key's name and the organizations your swarms -are shared in. When cfcli isn't signed in, or the key no longer works, it says -so and exits with code 20. +and lighthouse configurations are shared in. When cfcli isn't signed in, or +the key no longer works, it says so and exits with code 20. ## Sign out @@ -58,7 +59,8 @@ Revokes cfcli's key on the server and forgets it. ## The key cfcli signs in with an API key of its own, named "cfcli on <computer>". It -reads and uploads swarms as you in the organizations you are a member of. It +reads and uploads swarms and lighthouse configurations as you in the +organizations you are a member of. It is listed on the server's API keys page, where you can revoke it; that signs cfcli out too. diff --git a/docs/lighthouse.md b/docs/lighthouse.md index 65e1e51..2d42957 100644 --- a/docs/lighthouse.md +++ b/docs/lighthouse.md @@ -41,12 +41,28 @@ the lighthouse memory. Usage: cfcli lh config Commands: - display Display lighthouse configuration in human-readable form - read Read lighthouse configuration as YAML (to file or stdout) - write Write lighthouse configuration from YAML (from file or stdin) - check Compare the Crazyflie's lighthouse configuration with YAML (from file or stdin) + list List the stored lighthouse configs: local ones and shared ones (/) + display Display a lighthouse configuration: a stored one, a file, or the Crazyflie's + read Read the Crazyflie's lighthouse configuration as YAML (to file or stdout) + save Store the Crazyflie's lighthouse configuration as (new, or an update) + write Write a lighthouse configuration to the Crazyflie + check Compare the Crazyflie's lighthouse configuration with one + import Store a lighthouse configuration file (from the Crazyflie client or read) + export Write a stored lighthouse configuration to a file the Crazyflie client opens (or stdout) + name Show or set the name shown for a lighthouse config (its ID stays the same; 'move' changes the ID) + delete Delete a stored lighthouse configuration + move Share a lighthouse config (cage -> org/cage), take it back (org/cage -> cage), or rename it + pull Get the latest version of the shared lighthouse configs (needed with sync off) + push Upload changes to shared lighthouse configs made with sync off or without the server ``` +`display`, `write` and `check` take a stored configuration by its ID, or a +file with `-i`. `write` and `check` also read YAML piped in. With none of +these, `write` lists the stored configurations to pick one from, and `check` +uses the configuration the selected swarm names (see +[Swarms](/docs/swarm.md#lighthouse)). Without a terminal, as for a command in +a script or a cron job, `write` uses the selected swarm's too. + ### YAML File Format The format matches the one used by the Python `cflib` so configurations can be @@ -100,6 +116,8 @@ Top-level fields: cflib - `geos` — map of `bs_id -> { origin, rotation }` - `calibs` — map of `bs_id -> { uid, sweeps[2] }` +- `name` — optional, the name shown for a stored configuration (cflib + ignores it) Either map can be omitted or empty if you only want to read/write one half. The maps are written in base station order, so reading the same configuration @@ -111,11 +129,12 @@ Render the current configuration in human-readable form, either from the Crazyflie or from a YAML file. ```text -cfcli lh config display [-i ] +cfcli lh config display [ | -i ] ``` Options: +- `` — a stored configuration instead of the Crazyflie's - `-i, --input ` — read from a YAML file instead of the Crazyflie When `--csv` is used (the global flag), `display` emits a long-format CSV @@ -146,6 +165,9 @@ cfcli lh config display # Pretty print a YAML file (no Crazyflie connection) cfcli lh config display -i my_setup.yaml +# Pretty print a stored configuration +cfcli lh config display lab/cage + # Machine-readable CSV cfcli lh config display --csv @@ -183,16 +205,31 @@ diff <(cfcli lh config read) my_reference.yaml ### Write -Write a configuration from a YAML file (or stdin) to the Crazyflie. +Write a configuration to the Crazyflie. ```text -cfcli lh config write [-i ] +cfcli lh config write [ | -i ] ``` Options: -- `-i, --input ` — read YAML from a file. If omitted, YAML is read from - stdin. +- `` — a stored configuration +- `-i, --input ` — read YAML from a file + +With neither, YAML piped in is written. Otherwise, in a terminal, `write` +lists all the stored configurations (local ones and the shared ones in all +your organizations) to pick one from, starting at the one the selected swarm +names. Without a terminal it writes the selected swarm's configuration. Stdin +that isn't a terminal but has nothing in it (a script, a cron job) counts as +nothing piped in. + +```text +$ cfcli lh config write +? Lighthouse config to write: + cage - Local cage (4 base stations) +> lab/cage - The cage (4 base stations) + other/room - Other room (4 base stations) +``` All base station slots the firmware supports are written. Slots present in the YAML are uploaded as valid, while slots omitted from the YAML are written @@ -210,23 +247,27 @@ default firmware. # Write a config from a file cfcli lh config write -i my_setup.yaml +# Write a stored (or shared) config +cfcli lh config write lab/cage + +# Pick one of the stored configs +cfcli lh config write + # Pipe YAML in from stdin cat my_setup.yaml | cfcli lh config write ``` ### Check -Compare the configuration on the Crazyflie with a YAML file (or stdin), base -station by base station. +Compare the configuration on the Crazyflie with another one, base station by +base station. ```text -cfcli lh config check [-i ] +cfcli lh config check [ | -i ] ``` -Options: - -- `-i, --input ` — read YAML from a file. If omitted, YAML is read from - stdin. +The configuration is chosen as for [write](#write), except that without one +`check` doesn't ask: it uses the configuration the selected swarm names. Values are compared exactly, as the Crazyflie stores them. For each base station, the geometry and the calibration are: @@ -263,13 +304,89 @@ bs_id,geometry,moved_m,turned_deg,calibration,file_uid,cf_uid cfcli lh config check -i my_setup.yaml # Write only when it differs -cfcli lh config check -i my_setup.yaml; [ $? -eq 60 ] && cfcli lh config write -i my_setup.yaml +cfcli lh config check lab/cage; [ $? -eq 60 ] && cfcli lh config write lab/cage +``` + +## Stored and shared configurations + +cfcli keeps lighthouse configurations the way it keeps swarms: local ones in a +`lighthouse` folder next to the cfcli config, named ``, and, when +signed in (`cfcli auth login`), shared ones on the server, named +`/`. A shared configuration has revisions, so when the base +stations are moved, everyone gets the new geometry, and the server's web +page shows what changed. With sync on (the default), commands check the +server for the latest version; with sync off, or without the server, they use +this computer's copy, and `pull` and `push` sync them, as for +[swarms](/docs/swarm.md#sync). + +```text +# Store what a Crazyflie has (after estimating the geometry in cfclient) +cfcli lh config save lab/cage --name "The cage" + +# ... or a file saved by the Crazyflie client +cfcli lh config import Lighthouse_Cage.yaml --id lab/cage --name "The cage" + +# List them +$ cfcli lh config list +ID | Name | Base stations | Stored +---------+----------+---------------+----------------------------- +lab/cage | The cage | 4 | arc.bitcraze.io, revision 2 + +# Give it to a Crazyflie, or check one +cfcli lh config write lab/cage +cfcli lh config check lab/cage + +# A file the Crazyflie client opens +cfcli lh config export lab/cage -o cage.yaml +``` + +`save` and `import` to an existing ID show what changes for each base station +and ask before replacing it (`--force` replaces without asking, and is needed +when not interactive). A configuration without any base station positions is +refused: estimate the geometry first. `base stations` in the list counts the +positioned ones; a Crazyflie also keeps the calibration of base stations it +has seen elsewhere. + +`name` shows the name a configuration is shown with, or gives it one; the ID +stays the same, and a shared configuration gets a new revision. Files from +the Crazyflie client have no name, so this names one after importing it +(`import --name` names it at once): + +```text +cfcli lh config name lab/cage "The cage" +cfcli lh config name lab/cage # prints: The cage +``` + +`move` shares a local configuration (`move cage lab/cage`), takes a shared one +back (which deletes it on the server for everyone in the organization), or +renames one. Swarms that fly in it keep the old ID: `move` lists them and the +`cfcli swarm config lh` command that gives them the new one. A local swarm +can only name a configuration on this computer, and a shared swarm one in its +organization, so not every swarm can follow: taking a shared configuration +back, for example, leaves its shared swarms without it. The server refuses a +shared swarm that names another organization's configuration, so that +everyone who sees the swarm can see it. + +`delete` deletes a configuration, a shared one on the server for everyone in +the organization. Before asking, it lists the swarms that fly in it: the +local ones and, for a shared configuration, the organization's shared swarms +(the server's list, or this computer's copies when the server can't be +reached). They keep naming it, so give them another one, or none with +`cfcli swarm config lh --clear`. + +```text +$ cfcli lh config delete lab/cage +Swarms that fly in lighthouse config 'lab/cage': 'lab/flight-test' +? Delete lighthouse config 'lab/cage' on arc.bitcraze.io, for everyone in lab? Yes +Deleted lighthouse config 'lab/cage' +'lab/flight-test' still names it: give it another with 'cfcli swarm config lh --swarm ', or none with '--clear' ``` ## Copy a Configuration Between Crazyflies -Read from one Crazyflie and write to another by piping `read` into `write`, -overriding the `--uri` for each: +Store it from one Crazyflie and write it to the others (`save`, then `write` +or `swarm lh write`), or pipe `read` into `write`, overriding the `--uri` for +each: ```bash cfcli --uri radio://0/80/2M/E7E7E7E7E7 lh config read \ diff --git a/docs/settings.md b/docs/settings.md index d2e0e33..ca6750e 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -11,7 +11,7 @@ Commands: show Show all current settings timeout Manage the connection timeout address Manage scan addresses - sync Whether swarm commands sync shared swarms with the server + sync Whether commands sync shared swarms and lighthouse configs with the server help Print this message or the help of the given subcommand(s) ``` @@ -23,7 +23,7 @@ Display all current settings at once: cfcli settings show ``` -This prints the connection timeout, the configured scan addresses, the path to the preserved console history file (used by the global `-p`/`--preserve-console` flag — see [Console](console.md)), the selected swarm and the folder the swarms are stored in (see [Swarms](swarm.md)). +This prints the connection timeout, the configured scan addresses, the path to the preserved console history file (used by the global `-p`/`--preserve-console` flag — see [Console](console.md)), the selected swarm, whether sync is on, and the folders swarms and lighthouse configurations are stored in (see [Swarms](swarm.md) and [Lighthouse](lighthouse.md#stored-and-shared-configurations)). ## Connection timeout @@ -92,12 +92,13 @@ If all addresses are removed, the list is automatically reset to the default cfcli settings address clear ``` -## Syncing shared swarms +## Syncing shared swarms and lighthouse configs -Whether swarm commands check the server before using a [shared swarm](swarm.md#sharing-swarms) +Whether commands check the server before using a [shared swarm](swarm.md#sharing-swarms) +or [lighthouse configuration](lighthouse.md#stored-and-shared-configurations) and upload changes right away (on, the default), or use this computer's copies -so they run faster (off). With sync off, `cfcli swarm config pull` and `push` -sync the copies. +so they run faster (off). With sync off, `cfcli swarm config pull`/`push` and +`cfcli lh config pull`/`push` sync the copies. ```bash cfcli settings sync show diff --git a/docs/swarm.md b/docs/swarm.md index f4fdd96..5fe1793 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -195,6 +195,12 @@ cfcli swarm config move lab bitcraze-lab/lab cfcli swarm config move bitcraze-lab/lab lab ``` +A swarm only names a lighthouse configuration it can name where it ends up +(see [Lighthouse](#lighthouse)): one on this computer for a local swarm, one +in its organization for a shared one. When a move or an import takes it +somewhere it can't name its configuration, it names none afterwards, and +cfcli says so. + `move` also renames a swarm (`move lab lab-old`). `import` can put a file straight into a shared swarm with `--id bitcraze-lab/`. @@ -285,6 +291,7 @@ These commands work like the normal ones, on every Crazyflie in the swarm: | `cfcli swarm deck list` | The decks on each Crazyflie | | `cfcli swarm debug assert` | The assert info of each Crazyflie | | `cfcli swarm bootload info \| flash` | Bootloaders and firmware, see [Flashing](#flashing) | +| `cfcli swarm lh check \| write` | The lighthouse configuration of each Crazyflie, see [Lighthouse](#lighthouse) | `--cf` and `--exclude` (comma-separated names) pick some of the Crazyflies, and `--swarm ` runs on another swarm than the selected one: @@ -484,6 +491,65 @@ so a plan that would put them together is refused before anything is changed. Crazyflies that still have the default address need their own address first, with `cfcli config set address=...`. +## Lighthouse + +A swarm can name the lighthouse configuration it flies in, a stored one (see +[Lighthouse](/docs/lighthouse.md#stored-and-shared-configurations)). A local +swarm names a configuration on this computer, and a shared swarm one in its +organization, so everyone who uses the swarm can get it. + +```text +cfcli swarm config lh lab/cage # the selected swarm flies in lab/cage +cfcli swarm config lh # pick one from a list +cfcli swarm config lh --clear +``` + +Without a configuration, `swarm config lh` lists the ones to pick from, +starting at the one the swarm names: the configurations on this computer for +a local swarm, and those in its organization for a shared one. When not +interactive, it shows the one the swarm names, as `swarm config show` does. + +`swarm lh check` compares each Crazyflie's configuration with it, and +`swarm lh write` writes it to the Crazyflies that don't have it yet (all of +them with `--force`), storing it in their flash. `--config` uses another +configuration than the one the swarm names. + +```text +$ cfcli swarm lh check +Lighthouse config 'lab/cage' (revision 3): base stations 0, 1, 2, 3 +CF | Lighthouse +------+------------------------------------------ +CF-01 | up to date +CF-02 | BS 2 moved 4.1 cm, turned 0.62° +CF-03 | no position for BS 0, 1, 2, 3 +Error: differs: 2 of 3 Crazyflies have another lighthouse configuration; 'cfcli swarm lh write' gives them this one + +$ cfcli swarm lh write +Lighthouse config 'lab/cage' (revision 3): base stations 0, 1, 2, 3 +CF-01: up to date +CF-02: written and stored in flash +CF-03: written and stored in flash +``` + +`check` exits with 60 when a Crazyflie differs, like `lh config check`, and +`--csv` gives one row per Crazyflie (`cf,uri,status,firmware_base_stations, +differing_base_stations`). `write` refuses a Crazyflie whose firmware supports +fewer base stations than the configuration has, before writing anything to it. + +`write` reads each configuration back after writing it. A Crazyflie that sees +a base station whose UID isn't the configuration's takes that base station's +calibration, so the configuration doesn't stay; `write` then fails for that +Crazyflie and says which base stations: + +```text +CF-02: written, but then the Crazyflie took the calibration of the base stations it sees, which aren't the config's (BS 1 sees 0x2E08C9A4, the config has 0x8CADF4AC). If a base station was replaced, its geometry may need a new estimate; then store the configuration again with 'cfcli lh config save' +``` + +When Crazyflies are added to a swarm that names a lighthouse config, `add` +says how to give it to them (`cfcli swarm lh write --swarm --cf +CF-07`). When the configuration changes, `swarm lh write` updates the +Crazyflies that have the old one. + ## Using one Crazyflie from a swarm `select --from-swarm` selects a Crazyflie from the selected swarm for all the @@ -504,7 +570,8 @@ Swarms are files in a `swarms` folder next to the cfcli config file, on Linux and the selected swarm. Use `import` and `export` rather than editing the files. The copies of shared swarms are kept apart from them, in -`~/.config/cf-cli/synced///.yaml`, with +`~/.config/cf-cli/synced//swarms//.yaml` +(shared lighthouse configs next to them in `.../lighthouse/...`), with `state.json` saying which revision each copy is and whether it has changes that aren't pushed, and `orgs.json` remembering your organizations' IDs. Don't edit those; change shared swarms with the commands. @@ -514,6 +581,7 @@ edit those; change shared swarms with the commands. ```yaml name: Lab Crazyflies description: The bench +lighthouse: cage units: - uri: radio:///80/2M/E7E7E7E701 name: CF-01 @@ -522,5 +590,6 @@ units: description: Test bench ``` -`description` is optional, the rest is required. Fields cfcli doesn't know are -kept when it rewrites a file. +`description` and `lighthouse` (the lighthouse config the swarm flies in, see +[Lighthouse](#lighthouse)) are optional, the rest is required. Fields cfcli +doesn't know are kept when it rewrites a file. diff --git a/src/cli.rs b/src/cli.rs index 1cc1476..54534d9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -200,7 +200,7 @@ const HELP_EPILOG: &str = "\x1b[1m\x1b[4mExit codes:\x1b[0m 30 invalid value (range, type, malformed input) 40 --timeout expired on a bounded command 50 a swarm command failed on some of the Crazyflies - 60 a check found differences (lh config check) + 60 a check found differences (lh config check, swarm lh check) "; #[derive(Parser, Debug)] @@ -353,7 +353,7 @@ enum Commands { command: SwarmCommands, }, - /// Your account for sharing swarms: sign in, sign out + /// Your account for sharing swarms and lighthouse configs: sign in, sign out Auth { #[clap(subcommand)] command: AuthCommands, @@ -383,6 +383,8 @@ enum CompletionKind { SwarmConfigs, /// Crazyflie names in the selected swarm (for `--cf` and `select --from-swarm`) SwarmUnits, + /// Stored lighthouse config IDs (for `lh config` and `--config`) + LhConfigs, } #[derive(Debug, Subcommand)] @@ -396,33 +398,104 @@ enum LighthouseCommands { #[derive(Debug, Subcommand)] enum LighthouseConfigCommands { - /// Display lighthouse configuration in human-readable form + /// List the stored lighthouse configs: local ones and shared ones (/) + List, + /// Display a lighthouse configuration: a stored one, a file, or the Crazyflie's Display(LighthouseDisplayParameters), - /// Read lighthouse configuration as YAML (to file or stdout) + /// Read the Crazyflie's lighthouse configuration as YAML (to file or stdout) Read(LighthouseReadParameters), - /// Write lighthouse configuration from YAML (from file or stdin) + /// Store the Crazyflie's lighthouse configuration as (new, or an update) + Save(LighthouseSaveParameters), + /// Write a lighthouse configuration to the Crazyflie + /// + /// The configuration is a stored one (), a file (-i), YAML piped + /// in, or else one picked from a list (in a terminal) or the one the + /// selected swarm names. + #[clap(verbatim_doc_comment)] Write(LighthouseWriteParameters), - /// Compare the Crazyflie's lighthouse configuration with YAML (from file or stdin) + /// Compare the Crazyflie's lighthouse configuration with one + /// + /// The configuration is a stored one (), a file (-i), YAML piped + /// in, or else the one the selected swarm names. + #[clap(verbatim_doc_comment)] Check(LighthouseCheckParameters), + /// Store a lighthouse configuration file (from the Crazyflie client or read) + Import(LighthouseImportParameters), + /// Write a stored lighthouse configuration to a file the Crazyflie client opens (or stdout) + Export(LighthouseExportParameters), + /// Show or set the name shown for a lighthouse config (its ID stays the same; 'move' changes the ID) + Name { + /// Lighthouse config to name + #[clap(value_name = "CONFIG")] + id: String, + /// New name (shows the current one if omitted) + #[clap(value_name = "NAME")] + name: Option, + }, + /// Delete a stored lighthouse configuration + Delete { + /// Lighthouse config ID (prompts for one if omitted) + #[clap(value_name = "CONFIG")] + id: Option, + }, + /// Share a lighthouse config (cage -> org/cage), take it back (org/cage -> cage), or rename it + /// + /// Taking a shared one back deletes it on the server, for everyone in the + /// organization. + Move { + /// The lighthouse config to move + #[clap(value_name = "CONFIG")] + from: String, + /// Its new ID: on this computer, / on the server + #[clap(value_name = "NEW_ID")] + to: String, + }, + /// Get the latest version of the shared lighthouse configs (needed with sync off) + Pull { + /// Only this shared lighthouse config (/) + #[clap(value_name = "CONFIG")] + id: Option, + /// Drop changes on this computer that aren't pushed + #[clap(long)] + force: bool, + }, + /// Upload changes to shared lighthouse configs made with sync off or without the server + Push { + /// Only this shared lighthouse config (/) + #[clap(value_name = "CONFIG")] + id: Option, + /// Overwrite what others uploaded since + #[clap(long)] + force: bool, + }, } #[derive(Debug, Args)] struct LighthouseDisplayParameters { - /// YAML file to display (reads from Crazyflie if omitted) + /// Stored lighthouse config to display + #[clap(value_name = "CONFIG", conflicts_with = "input")] + id: Option, + /// YAML file to display (reads from the Crazyflie if neither is given) #[clap(long, short = 'i', value_hint = ValueHint::FilePath)] input: Option, } #[derive(Debug, Args)] struct LighthouseWriteParameters { - /// YAML file to read configuration from (reads stdin if omitted) + /// Stored lighthouse config to write + #[clap(value_name = "CONFIG", conflicts_with = "input")] + id: Option, + /// YAML file to read the configuration from #[clap(long, short = 'i', value_hint = ValueHint::FilePath)] input: Option, } #[derive(Debug, Args)] struct LighthouseCheckParameters { - /// YAML file to compare with (reads stdin if omitted) + /// Stored lighthouse config to compare with + #[clap(value_name = "CONFIG", conflicts_with = "input")] + id: Option, + /// YAML file to compare with #[clap(long, short = 'i', value_hint = ValueHint::FilePath)] input: Option, } @@ -434,6 +507,45 @@ struct LighthouseReadParameters { output: Option, } +#[derive(Debug, Args)] +struct LighthouseSaveParameters { + /// ID to store it as (letters, digits, '-', '_' and '.'); / shares it on the server + #[clap(value_name = "CONFIG")] + id: String, + /// Name shown for the configuration + #[clap(long)] + name: Option, + /// Replace an existing configuration without asking + #[clap(long)] + force: bool, +} + +#[derive(Debug, Args)] +struct LighthouseImportParameters { + /// The file (Crazyflie client format) + #[clap(value_hint = ValueHint::FilePath)] + file: String, + /// ID to store it as (default: the file name); / shares it on the server + #[clap(long, value_name = "CONFIG")] + id: Option, + /// Name shown for the configuration + #[clap(long)] + name: Option, + /// Replace an existing configuration without asking + #[clap(long)] + force: bool, +} + +#[derive(Debug, Args)] +struct LighthouseExportParameters { + /// Stored lighthouse config to export + #[clap(value_name = "CONFIG")] + id: String, + /// File to write (stdout if omitted) + #[clap(long, short = 'o', value_hint = ValueHint::FilePath)] + output: Option, +} + #[derive(Debug, Subcommand)] enum DebugCommands { /// Trigger a firmware assert-info dump on the console @@ -564,6 +676,39 @@ enum SwarmCommands { }, /// Spread the swarm over several radio channels (reprograms and reboots the Crazyflies that move) Rechannel(SwarmRechannelParameters), + /// Lighthouse configuration of the Crazyflies in the swarm + Lh { + #[clap(flatten)] + target: SwarmTargetArgs, + + #[clap(subcommand)] + command: SwarmLhCommands, + }, +} + +#[derive(Debug, Subcommand)] +enum SwarmLhCommands { + /// Compare each Crazyflie's lighthouse configuration with the swarm's + Check(SwarmLhParameters), + /// Write the swarm's lighthouse configuration to the Crazyflies that don't have it + Write(SwarmLhWriteParameters), +} + +#[derive(Debug, Args)] +struct SwarmLhParameters { + /// Lighthouse config to use instead of the one the swarm names + #[clap(long, value_name = "CONFIG")] + config: Option, +} + +#[derive(Debug, Args)] +struct SwarmLhWriteParameters { + /// Lighthouse config to use instead of the one the swarm names + #[clap(long, value_name = "CONFIG")] + config: Option, + /// Write it also to Crazyflies that already have it + #[clap(long)] + force: bool, } #[derive(Debug, Args)] @@ -727,6 +872,18 @@ enum SwarmConfigCommands { #[clap(value_name = "NEW_ID")] to: String, }, + /// Set the lighthouse config the swarm flies in, or show it when not interactive (see 'cfcli lh config') + Lh { + /// Lighthouse config ID, picked from a list if omitted (a local swarm names one on this computer, a shared swarm one in its organization) + #[clap(value_name = "CONFIG")] + config: Option, + /// The swarm flies in no particular lighthouse config + #[clap(long, conflicts_with = "config")] + clear: bool, + /// Swarm to change instead of the selected one + #[clap(long, value_name = "SWARM")] + swarm: Option, + }, /// Get the latest version of the shared swarms (needed with sync off) Pull { /// Only this shared swarm (/) @@ -853,7 +1010,7 @@ enum SettingsCommands { #[clap(subcommand)] command: SettingsAddressCommands, }, - /// Whether swarm commands sync shared swarms with the server + /// Whether commands sync shared swarms and lighthouse configs with the server Sync { #[clap(subcommand)] command: SettingsSyncCommands, diff --git a/src/main.rs b/src/main.rs index 3ac5740..7b06672 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,7 @@ pub mod modules { pub mod debug; pub mod lighthouse; pub mod deck; + pub mod documents; pub mod platform; pub mod swarm; } @@ -156,7 +157,7 @@ pub struct Config { /// ID of the selected swarm (see `cfcli swarm config select`) #[serde(default)] swarm: Option, - /// Whether swarm commands sync shared swarms with the server (see + /// Whether commands sync shared swarms and lighthouse configs with the server (see /// `cfcli settings sync`); None is the default, on. #[serde(default)] sync: Option, @@ -536,6 +537,9 @@ fn emit_dynamic_completions(kind: CompletionKind, partial: &str) { .and_then(|swarms| swarms.cached_ids()) .unwrap_or_default(), CompletionKind::SwarmUnits => selected_swarm_units(), + CompletionKind::LhConfigs => modules::lighthouse::configs::LhConfigs::open_cached() + .and_then(|configs| configs.cached_ids()) + .unwrap_or_default(), }; let stdout = std::io::stdout(); @@ -1442,17 +1446,22 @@ async fn run() -> Result<()> { } } Commands::Lh { command: LighthouseCommands::Config { command } } => { - // Files are loaded before connecting, so a bad one fails at once. + use modules::lighthouse::configs::{self, LhConfigs}; + // Files and stored configurations are loaded before connecting, + // so a bad one fails at once. match command { - LighthouseConfigCommands::Display(params) => match ¶ms.input { - Some(file_path) => modules::lighthouse::display_file(file_path, csv)?, - None => { + LighthouseConfigCommands::List => configs::list(&LhConfigs::open(&config)?, csv).await?, + LighthouseConfigCommands::Display(params) => match (¶ms.id, ¶ms.input) { + (Some(id), _) => configs::show(&LhConfigs::open(&config)?, id, csv).await?, + (None, Some(file_path)) => modules::lighthouse::display_file(file_path, csv)?, + (None, None) => { let cf = connect_cf(&mut connected_cf, &link_context, uri.as_str(), toc_cache, args.debug).await?; modules::lighthouse::display(cf, csv, non_interactive).await?; } }, LighthouseConfigCommands::Write(params) => { - let file = modules::lighthouse::load(params.input.as_deref())?; + let (file, _) = + configs::source(&config, params.id.as_deref(), params.input.as_deref(), !non_interactive).await?; let cf = connect_cf(&mut connected_cf, &link_context, uri.as_str(), toc_cache, args.debug).await?; modules::lighthouse::write(cf, &file, non_interactive).await?; } @@ -1461,10 +1470,48 @@ async fn run() -> Result<()> { modules::lighthouse::read(cf, params.output.as_deref(), non_interactive).await?; } LighthouseConfigCommands::Check(params) => { - let file = modules::lighthouse::load(params.input.as_deref())?; - let source = params.input.as_deref().unwrap_or("the configuration from stdin"); + let (file, source) = + configs::source(&config, params.id.as_deref(), params.input.as_deref(), false).await?; + let cf = connect_cf(&mut connected_cf, &link_context, uri.as_str(), toc_cache, args.debug).await?; + modules::lighthouse::check(cf, &file, &source, csv, non_interactive).await?; + } + LighthouseConfigCommands::Save(params) => { + let lh_configs = LhConfigs::open(&config)?; let cf = connect_cf(&mut connected_cf, &link_context, uri.as_str(), toc_cache, args.debug).await?; - modules::lighthouse::check(cf, &file, source, csv, non_interactive).await?; + configs::save(&lh_configs, cf, ¶ms.id, params.name.as_deref(), params.force, non_interactive).await?; + } + LighthouseConfigCommands::Import(params) => { + configs::import( + &LhConfigs::open(&config)?, + ¶ms.file, + params.id.as_deref(), + params.name.as_deref(), + params.force, + non_interactive, + ) + .await? + } + LighthouseConfigCommands::Export(params) => { + configs::export(&LhConfigs::open(&config)?, ¶ms.id, params.output.as_deref()).await? + } + LighthouseConfigCommands::Name { id, name } => { + configs::name(&LhConfigs::open(&config)?, id, name.as_deref()).await? + } + LighthouseConfigCommands::Delete { id } => { + configs::delete(&LhConfigs::open(&config)?, id.as_deref(), non_interactive).await? + } + LighthouseConfigCommands::Move { from, to } => { + configs::move_config(&LhConfigs::open(&config)?, from, to, non_interactive).await? + } + LighthouseConfigCommands::Pull { id, force } => { + let lh_configs = LhConfigs::open(&config)?; + let (shared, only) = lh_configs.for_sync(id.as_deref())?; + shared.pull(only.as_ref(), *force).await? + } + LighthouseConfigCommands::Push { id, force } => { + let lh_configs = LhConfigs::open(&config)?; + let (shared, only) = lh_configs.for_sync(id.as_deref())?; + shared.push(only.as_ref(), *force).await? } } } diff --git a/src/modules/auth.rs b/src/modules/auth.rs index 19ee66c..333edfc 100644 --- a/src/modules/auth.rs +++ b/src/modules/auth.rs @@ -199,7 +199,7 @@ fn print_orgs(server: &str, orgs: &[OrgInfo]) { ); return; } - println!("Swarms are shared in:"); + println!("Swarms and lighthouse configs are shared in:"); for org in orgs { println!(" {} ({}, {})", org.name, org.slug, org.role); } diff --git a/src/modules/documents.rs b/src/modules/documents.rs new file mode 100644 index 0000000..64edfac --- /dev/null +++ b/src/modules/documents.rs @@ -0,0 +1,1317 @@ +//! Documents cfcli keeps: swarms and lighthouse configurations. Each kind is +//! a YAML file, kept locally in a folder next to the cfcli config (`swarms`, +//! `lighthouse`) and named ``, and, when cfcli is signed in (`cfcli auth +//! login`), also shared on the server, named `/`. Every command +//! takes either kind of ID. +//! +//! cfcli keeps a copy of each shared document it uses in +//! `synced////.yaml` next to the cfcli config. That +//! is outside the local folders, so Swarmkeeper and `import` never take a +//! copy for a local document. `synced//state.json` says which +//! revision each copy is and whether it has changes the server doesn't have +//! yet. +//! +//! With sync on (`cfcli settings sync`, the default) a command checks the +//! server before it uses a shared document, which costs a 304 when the copy +//! is current, and writes changes straight to the server. The upload carries +//! `If-Match` with the copy's revision, so nobody's upload is overwritten: +//! when someone else changed the document in between, the change is applied +//! again to their version. With sync off, commands use the copies only, and +//! `pull` and `push` sync them. +//! +//! When the server can't be reached, commands use the copies and keep their +//! changes for the next push, so working with the Crazyflies never waits for +//! the internet. +//! +//! The `` in an ID is the user's own ID for the organization, which they +//! can change on the server. `synced//orgs.json` remembers the ID of +//! each organization (the server lists their UUIDs, which never change), so +//! when one changes, the copies of every kind, the selected swarm and the +//! swarms' links to lighthouse configurations move to the new ID, and the +//! old ID says what the new one is. + +use std::collections::BTreeMap; +use std::fmt; +use std::marker::PhantomData; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use anyhow::{bail, Context, Result}; +use reqwest::{header, RequestBuilder, Response, StatusCode}; +use serde::{Deserialize, Serialize}; + +use super::swarm::store::Swarm; +use crate::error::CliError; +use crate::modules::auth::{self, Credentials}; +use crate::Config; + +/// One kind of document. +pub trait Document: Sized { + /// The local folder, the folder of the synced copies, the key in the + /// state file and the server's API path: `swarms`. + const FOLDER: &'static str; + /// What one is called in messages: `swarm`. + const NOUN: &'static str; + /// The commands that handle them, for hints: `swarm config`. + const COMMAND: &'static str; + /// The item count in the server's list: `units`. + const SERVER_COUNT: &'static str; + + fn from_yaml(yaml: &str) -> Result; + fn to_yaml(&self) -> Result; + /// The name (may be empty) and item count, for lists. + fn summary(&self) -> (String, usize); + + /// Change organization `old` to `new` in the documents this one names + /// (a swarm its lighthouse configuration). Returns whether it changed. + fn rename_org(&mut self, _old: &str, _new: &str) -> bool { + false + } +} + +/// IDs are file names: letters, digits, '-', '_' and '.', not first. +pub fn check_id(id: &str) -> Result<()> { + if !is_id(id) { + bail!(CliError::InvalidValue(format!( + "{} ID '{}': use letters, digits, '-', '_' and '.' (not first)", + D::NOUN, + id + ))); + } + Ok(()) +} + +fn is_id(id: &str) -> bool { + !id.is_empty() && !id.starts_with('.') && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) +} + +// ---- Local documents ---- + +/// A folder of documents of one kind. +pub struct Store { + dir: PathBuf, + kind: PhantomData D>, +} + +impl Store { + /// The local documents, next to the cfcli config file. + pub fn open() -> Result { + let config = confy::get_configuration_file_path("cf-cli", None) + .context("could not find the cfcli config folder")?; + Ok(Store::at(config.with_file_name(D::FOLDER))) + } + + /// A store in another folder: the copies of shared documents, and tests. + pub fn at(dir: PathBuf) -> Self { + Store { dir, kind: PhantomData } + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + fn path(&self, id: &str) -> PathBuf { + self.dir.join(format!("{}.yaml", id)) + } + + pub fn exists(&self, id: &str) -> bool { + self.path(id).is_file() + } + + /// The IDs of all stored documents, sorted. + pub fn ids(&self) -> Result> { + let entries = match std::fs::read_dir(&self.dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e).with_context(|| format!("reading {}", self.dir.display())), + }; + let mut ids: Vec = entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "yaml")) + .filter_map(|path| path.file_stem().map(|stem| stem.to_string_lossy().into_owned())) + .filter(|id| is_id(id)) + .collect(); + ids.sort(); + Ok(ids) + } + + /// The file exactly as stored. + pub fn read_raw(&self, id: &str) -> Result { + let path = self.path(id); + match std::fs::read_to_string(&path) { + Ok(yaml) => Ok(yaml), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(CliError::NotFound(format!( + "{} '{}' (see 'cfcli {} list')", + D::NOUN, + id, + D::COMMAND + ))), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } + } + + pub fn load(&self, id: &str) -> Result { + D::from_yaml(&self.read_raw(id)?).with_context(|| format!("in {}", self.path(id).display())) + } + + /// Write a document, replacing any old file in one step. + pub fn save(&self, id: &str, document: &D) -> Result<()> { + check_id::(id)?; + std::fs::create_dir_all(&self.dir).with_context(|| format!("creating {}", self.dir.display()))?; + let path = self.path(id); + let tmp = self.dir.join(format!(".{}.yaml.tmp", id)); + std::fs::write(&tmp, document.to_yaml()?).with_context(|| format!("writing {}", tmp.display()))?; + std::fs::rename(&tmp, &path).with_context(|| format!("writing {}", path.display()))?; + Ok(()) + } + + pub fn delete(&self, id: &str) -> Result<()> { + let path = self.path(id); + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + bail!(CliError::NotFound(format!("{} '{}'", D::NOUN, id))) + } + Err(e) => Err(e).with_context(|| format!("deleting {}", path.display())), + } + } +} + +// ---- Shared documents ---- + +/// A shared document's ID, `/`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct SharedId { + pub org: String, + pub name: String, +} + +impl SharedId { + /// `/`, or None for a local ID (no '/'). + pub fn parse(id: &str) -> Result> { + let Some((org, name)) = id.split_once('/') else { + return Ok(None); + }; + let org_ok = !org.is_empty() && org.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); + if !org_ok { + bail!(CliError::InvalidValue(format!( + "ID '{}': the organization is lower-case letters, digits and '-'", + id + ))); + } + if !is_id(name) { + bail!(CliError::InvalidValue(format!( + "ID '{}': after the organization, use letters, digits, '-', '_' and '.' (not first)", + id + ))); + } + Ok(Some(SharedId { org: org.to_string(), name: name.to_string() })) + } +} + +impl fmt::Display for SharedId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.org, self.name) + } +} + +/// What cfcli knows about its copy of a shared document. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub struct CopyState { + /// The server's revision the copy is based on. 0 for a document created + /// here while the server couldn't be reached, which isn't on it yet. + pub revision: i32, + /// The copy has changes the server doesn't have. + pub pending: bool, +} + +/// `state.json`: the copies of each kind, by `/`. +type State = BTreeMap>; + +/// A shared document as `list` shows it. +pub struct Listed { + pub id: SharedId, + pub name: String, + pub count: usize, + pub copy: CopyState, +} + +/// A document in the server's list. +struct ServerItem { + id: SharedId, + /// Never changes, unlike the user's ID for the organization (`id.org`). + /// Servers before per-user IDs don't send it. + org_uuid: Option, + name: String, + count: usize, + revision: i32, +} + +/// `orgs.json`: the user's IDs for their organizations. +#[derive(Debug, Default, Serialize, Deserialize)] +struct OrgIds { + /// The ID of each organization, by UUID, as the server last listed it. + #[serde(default)] + ids: BTreeMap, + /// Old IDs and what they became, for telling the user. + #[serde(default)] + renamed: BTreeMap, +} + +/// What an upload asks the server to check first. +#[derive(Clone, Copy)] +enum Condition { + /// Only if the document is still at this revision. + Revision(i32), + /// Only if the document doesn't exist. + New, + /// Overwrite whatever is there. + None, +} + +enum Upload { + Done(i32), + /// Someone else changed (or created, or deleted) the document first. + Conflict, + Offline, +} + +/// The shared documents of one kind on the server cfcli is signed in to. +pub struct Shared { + credentials: Credentials, + /// `synced/` next to the cfcli config. + dir: PathBuf, + sync: bool, + client: reqwest::Client, + /// The server didn't answer once; don't wait for it again in this command. + offline: AtomicBool, + kind: PhantomData D>, +} + +impl Shared { + /// None when cfcli isn't signed in. Doesn't contact the server. + pub fn open(sync: bool) -> Result> { + let Some(credentials) = auth::load()? else { + return Ok(None); + }; + let config = confy::get_configuration_file_path("cf-cli", None) + .context("could not find the cfcli config folder")?; + // "localhost:3000" would be an invalid folder name on Windows. + let host = auth::host(&credentials.server).replace([':', '/', '\\'], "_"); + let dir = config.with_file_name("synced").join(host); + move_old_swarm_copies(&dir); + let client = reqwest::Client::builder() + .user_agent(concat!("cfcli/", env!("CARGO_PKG_VERSION"))) + .connect_timeout(Duration::from_secs(3)) + .timeout(Duration::from_secs(15)) + .build()?; + Ok(Some(Shared { credentials, dir, sync, client, offline: AtomicBool::new(false), kind: PhantomData })) + } + + pub fn host(&self) -> &str { + auth::host(&self.credentials.server) + } + + pub fn sync(&self) -> bool { + self.sync + } + + // ---- The copies and their state ---- + + fn copies(&self, org: &str) -> Store { + Store::at(self.dir.join(D::FOLDER).join(org)) + } + + fn state_path(&self) -> PathBuf { + self.dir.join("state.json") + } + + fn state(&self) -> Result { + read_state(&self.state_path()) + } + + /// The copies of this kind. + fn copy_states(&self) -> Result> { + Ok(self.state()?.remove(D::FOLDER).unwrap_or_default()) + } + + fn save_state(&self, state: &State) -> Result<()> { + std::fs::create_dir_all(&self.dir).with_context(|| format!("creating {}", self.dir.display()))?; + let path = self.state_path(); + let tmp = self.dir.join(".state.json.tmp"); + std::fs::write(&tmp, serde_json::to_string_pretty(state)?).with_context(|| format!("writing {}", tmp.display()))?; + std::fs::rename(&tmp, &path).with_context(|| format!("writing {}", path.display())) + } + + pub fn copy_state(&self, id: &SharedId) -> Result> { + Ok(self.copy_states()?.get(&id.to_string()).copied()) + } + + fn set_copy_state(&self, id: &SharedId, copy: Option) -> Result<()> { + let mut state = self.state()?; + let copies = state.entry(D::FOLDER.to_string()).or_default(); + match copy { + Some(copy) => copies.insert(id.to_string(), copy), + None => copies.remove(&id.to_string()), + }; + self.save_state(&state) + } + + /// Keep `document` as the copy of `id`. + fn keep(&self, id: &SharedId, document: &D, copy: CopyState) -> Result<()> { + self.copies(&id.org).save(&id.name, document)?; + self.set_copy_state(id, Some(copy)) + } + + fn forget(&self, id: &SharedId) -> Result<()> { + let copies = self.copies(&id.org); + if copies.exists(&id.name) { + copies.delete(&id.name)?; + } + self.set_copy_state(id, None) + } + + fn has_copy(&self, id: &SharedId) -> bool { + self.copies(&id.org).exists(&id.name) + } + + /// The copy, without asking the server. For shell completion and the + /// like, which must never wait for the network. + pub fn cached(&self, id: &SharedId) -> Result { + if !self.has_copy(id) { + if let Some(now) = self.renamed(id)? { + bail!(renamed_error::(id, &now)); + } + bail!(CliError::NotFound(format!("a copy of {} '{}' on this computer", D::NOUN, id))); + } + self.copies(&id.org).load(&id.name) + } + + /// The shared documents there are copies of, without asking the server. + pub fn cached_ids(&self) -> Vec { + self.copy_states() + .map(|copies| copies.keys().filter_map(|id| SharedId::parse(id).ok().flatten()).collect()) + .unwrap_or_default() + } + + // ---- Organization IDs ---- + + fn org_ids(&self) -> Result { + let path = self.dir.join("orgs.json"); + match std::fs::read_to_string(&path) { + Ok(text) => serde_json::from_str(&text).with_context(|| format!("reading {}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(OrgIds::default()), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } + } + + fn save_org_ids(&self, orgs: &OrgIds) -> Result<()> { + std::fs::create_dir_all(&self.dir).with_context(|| format!("creating {}", self.dir.display()))?; + let path = self.dir.join("orgs.json"); + std::fs::write(&path, serde_json::to_string_pretty(orgs)?).with_context(|| format!("writing {}", path.display())) + } + + /// What `id` is called now, if the user changed the ID of its + /// organization. Also selects the swarm under the new ID, if the + /// selected one is in that organization. + fn renamed(&self, id: &SharedId) -> Result> { + let Some(org) = self.org_ids()?.renamed.get(&id.org).cloned() else { + return Ok(None); + }; + let now = SharedId { org, name: id.name.clone() }; + select_renamed(&[(id.org.clone(), now.org.clone())]); + Ok(Some(now)) + } + + /// Follow the organizations whose ID the user changed since the last + /// list: the copies of every kind move to the new ID, and so do the + /// selected swarm and the swarms' links to lighthouse configurations. + fn follow_renames(&self, server: &[ServerItem]) -> Result<()> { + let mut orgs = self.org_ids()?; + let before = serde_json::to_string(&orgs)?; + let mut renames: Vec<(String, String)> = Vec::new(); + for item in server { + let Some(uuid) = &item.org_uuid else { continue }; + if let Some(old) = orgs.ids.insert(uuid.clone(), item.id.org.clone()) { + if old != item.id.org && !renames.contains(&(old.clone(), item.id.org.clone())) { + renames.push((old, item.id.org.clone())); + } + } + } + for (old, new) in &renames { + for to in orgs.renamed.values_mut().filter(|to| *to == old) { + *to = new.clone(); + } + orgs.renamed.insert(old.clone(), new.clone()); + } + // An ID in use is no longer an old one. + for item in server { + orgs.renamed.remove(&item.id.org); + } + if serde_json::to_string(&orgs)? != before { + self.save_org_ids(&orgs)?; + } + if renames.is_empty() { + return Ok(()); + } + + // Take all the moving copies out first, so that two organizations + // can swap IDs. The copies of every kind move, not only this one's. + let mut state = self.state()?; + let mut moving = Vec::new(); + for (kind, copies) in state.iter_mut() { + for (old, new) in &renames { + let ids: Vec = copies + .keys() + .filter_map(|key| SharedId::parse(key).ok().flatten()) + .filter(|id| id.org == *old) + .collect(); + for id in ids { + let copy = copies.remove(&id.to_string()).unwrap_or_default(); + let path = self.dir.join(kind).join(old).join(format!("{}.yaml", id.name)); + let yaml = match std::fs::read_to_string(&path) { + Ok(yaml) => { + std::fs::remove_file(&path).with_context(|| format!("moving {}", path.display()))?; + Some(yaml) + } + Err(_) => None, + }; + moving.push((kind.clone(), SharedId { org: new.clone(), name: id.name }, copy, yaml)); + } + } + } + for (kind, id, copy, yaml) in moving { + if let Some(yaml) = yaml { + let dir = self.dir.join(&kind).join(&id.org); + std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; + let path = dir.join(format!("{}.yaml", id.name)); + std::fs::write(&path, yaml).with_context(|| format!("writing {}", path.display()))?; + } + state.entry(kind).or_default().insert(id.to_string(), copy); + } + self.save_state(&state)?; + for kind in state.keys() { + for (old, _) in &renames { + // Only goes when empty. + let _ = std::fs::remove_dir(self.dir.join(kind).join(old)); + } + } + + // Swarms name their lighthouse configuration with the organization's + // ID: the local ones and the copies. (The server sends the copies + // with the new ID anyway; this keeps those not synced yet right.) + let mut stores = vec![Store::::open()?]; + for key in state.get(Swarm::FOLDER).into_iter().flat_map(|copies| copies.keys()) { + if let Some(id) = SharedId::parse(key).ok().flatten() { + let store = Store::::at(self.dir.join(Swarm::FOLDER).join(&id.org)); + if !stores.iter().any(|s| s.dir() == store.dir()) { + stores.push(store); + } + } + } + for store in &stores { + for id in store.ids()? { + let Ok(mut swarm) = store.load(&id) else { continue }; + let mut changed = false; + for (old, new) in &renames { + changed |= swarm.rename_org(old, new); + } + if changed { + store.save(&id, &swarm)?; + } + } + } + + select_renamed(&renames); + for (old, new) in &renames { + println!( + "Your organization '{}' on {} is now called '{}': its swarms and lighthouse configs are '{}/'", + old, + self.host(), + new, + new + ); + } + Ok(()) + } + + // ---- Talking to the server ---- + + fn url(&self, id: &SharedId) -> Result { + auth::endpoint(&self.credentials.server, &format!("/api/v1/{}/{}/{}", D::FOLDER, id.org, id.name)) + } + + /// Send a request with the key. None when the server can't be reached; + /// the first time, cfcli says it uses the copies instead. + async fn send(&self, request: RequestBuilder) -> Result> { + self.send_quietly(request, false).await + } + + async fn send_quietly(&self, request: RequestBuilder, quiet: bool) -> Result> { + if self.offline.load(Ordering::Relaxed) { + return Ok(None); + } + match request.bearer_auth(&self.credentials.key).send().await { + Ok(response) if response.status() == StatusCode::UNAUTHORIZED => bail!(CliError::NotFound(format!( + "a working key: {} no longer accepts '{}'; sign in again with 'cfcli auth login'", + self.host(), + self.credentials.key_name + ))), + Ok(response) => Ok(Some(response)), + Err(e) if e.is_connect() || e.is_timeout() => { + self.offline.store(true, Ordering::Relaxed); + if !quiet { + eprintln!( + "Can't reach {}: using the copies of shared {}s on this computer. \ + Changes are kept and uploaded {}.", + self.host(), + D::NOUN, + if self.sync { + "when it answers again".to_string() + } else { + format!("with 'cfcli {} push'", D::COMMAND) + } + ); + } + Ok(None) + } + Err(e) => Err(e).with_context(|| CliError::Connection(self.host().to_string())), + } + } + + /// Like [`Shared::send`], for things that can't be done without the server. + async fn send_online(&self, request: RequestBuilder, what: &str) -> Result { + match self.send_quietly(request, true).await? { + Some(response) => Ok(response), + None => bail!(CliError::Connection(format!("{} needs {}, which can't be reached", what, self.host()))), + } + } + + /// The documents on the server, in all the user's organizations. + async fn server_list(&self) -> Result>> { + let url = auth::endpoint(&self.credentials.server, &format!("/api/v1/{}", D::FOLDER))?; + let Some(response) = self.send(self.client.get(url)).await? else { + return Ok(None); + }; + let items: Vec> = + auth::answer(&self.credentials.server, response).await?; + let mut listed = Vec::new(); + for item in items { + let text = |key: &str| item.get(key).and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let Some(id) = SharedId::parse(&text("id")).ok().flatten() else { + continue; + }; + let number = |key: &str| item.get(key).and_then(|v| v.as_u64()).unwrap_or_default(); + listed.push(ServerItem { + id, + org_uuid: Some(text("org_uuid")).filter(|uuid| !uuid.is_empty()), + name: text("name"), + count: number(D::SERVER_COUNT) as usize, + revision: number("revision") as i32, + }); + } + self.follow_renames(&listed)?; + Ok(Some(listed)) + } + + /// A list the server keeps about `id`, `GET /api/v1////` + /// (the swarms that name a lighthouse configuration). None when the + /// server can't be reached; empty when it doesn't have `id`. + pub async fn related(&self, id: &SharedId, what: &str) -> Result>>> { + let url = auth::endpoint( + &self.credentials.server, + &format!("/api/v1/{}/{}/{}/{}", D::FOLDER, id.org, id.name, what), + )?; + let Some(response) = self.send_quietly(self.client.get(url), true).await? else { + return Ok(None); + }; + if response.status() == StatusCode::NOT_FOUND { + return Ok(Some(Vec::new())); + } + auth::answer(&self.credentials.server, response).await.map(Some) + } + + /// After the server answered 404 for `id`: fail saying what it is called + /// now, if the user changed the ID of its organization. Gets the list, + /// which moves the copies. + async fn fail_if_renamed(&self, id: &SharedId) -> Result<()> { + self.server_list().await?; + match self.renamed(id)? { + Some(now) => bail!(renamed_error::(id, &now)), + None => Ok(()), + } + } + + /// Download `id` into its copy unless the copy is current. Returns false + /// when the server can't be reached. + async fn download(&self, id: &SharedId, force: bool) -> Result { + let copy = self.copy_state(id)?.unwrap_or_default(); + let mut request = self.client.get(self.url(id)?); + if !force && copy.revision > 0 && self.has_copy(id) { + request = request.header(header::IF_NONE_MATCH, format!("\"{}\"", copy.revision)); + } + let Some(response) = self.send(request).await? else { + return Ok(false); + }; + match response.status() { + StatusCode::NOT_MODIFIED => Ok(true), + StatusCode::NOT_FOUND => { + self.fail_if_renamed(id).await?; + // Deleted on the server, or no longer visible to the user. + if copy.pending { + bail!(CliError::NotFound(format!( + "{} '{}' on {}, though this computer has changes to it that aren't uploaded; \ + 'cfcli {} push --force {}' creates it again with them", + D::NOUN, + id, + self.host(), + D::COMMAND, + id + ))); + } + self.forget(id)?; + bail!(CliError::NotFound(format!("{} '{}' on {}", D::NOUN, id, self.host()))) + } + status if status.is_success() => { + let revision = etag_revision(&response)?; + let yaml = response.text().await?; + let document = D::from_yaml(&yaml).with_context(|| format!("{} '{}' from {}", D::NOUN, id, self.host()))?; + self.keep(id, &document, CopyState { revision, pending: false })?; + Ok(true) + } + _ => auth::answer::(&self.credentials.server, response).await.map(|_| true), + } + } + + async fn upload(&self, id: &SharedId, document: &D, condition: Condition) -> Result { + let mut request = self + .client + .put(self.url(id)?) + .header(header::CONTENT_TYPE, "application/yaml") + .body(document.to_yaml()?); + request = match condition { + Condition::Revision(revision) => request.header(header::IF_MATCH, format!("\"{}\"", revision)), + Condition::New => request.header(header::IF_NONE_MATCH, "*"), + Condition::None => request, + }; + let Some(response) = self.send(request).await? else { + return Ok(Upload::Offline); + }; + if response.status() == StatusCode::PRECONDITION_FAILED { + return Ok(Upload::Conflict); + } + if response.status() == StatusCode::NOT_FOUND { + self.fail_if_renamed(id).await?; + } + #[derive(Deserialize)] + struct Uploaded { + revision: i32, + } + let uploaded: Uploaded = auth::answer(&self.credentials.server, response).await?; + Ok(Upload::Done(uploaded.revision)) + } + + // ---- What the commands do ---- + + /// Upload the copy of `id` if it has changes. Fails on a conflict, saying + /// how to settle it. Returns false when the server can't be reached. + async fn push_pending(&self, id: &SharedId, force: bool) -> Result { + let Some(copy) = self.copy_state(id)? else { + return Ok(true); + }; + if !copy.pending { + return Ok(true); + } + let document = self.copies(&id.org).load(&id.name)?; + let condition = match (force, copy.revision) { + (true, _) => Condition::None, + (false, 0) => Condition::New, + (false, revision) => Condition::Revision(revision), + }; + match self.upload(id, &document, condition).await? { + Upload::Done(revision) => { + self.set_copy_state(id, Some(CopyState { revision, pending: false }))?; + Ok(true) + } + Upload::Offline => Ok(false), + Upload::Conflict => bail!(CliError::InvalidValue(format!( + "{} '{}' changed on {} since this computer's copy ({}). Keep theirs with \ + 'cfcli {} pull --force {}', or yours with 'cfcli {} push --force {}'", + D::NOUN, + id, + self.host(), + match copy.revision { + 0 => "someone created it first".to_string(), + revision => format!("revision {}", revision), + }, + D::COMMAND, + id, + D::COMMAND, + id + ))), + } + } + + /// The document, checked with the server first when sync is on (or when + /// there's no copy yet). + pub async fn load(&self, id: &SharedId) -> Result { + if (self.sync || !self.has_copy(id)) && self.push_pending(id, false).await? { + self.download(id, false).await?; + } + self.cached(id) + } + + /// Change the document. `change` may run more than once: when someone + /// else uploaded in between, it runs again on their version. With sync + /// off, or without the server, it changes the copy and keeps it for a + /// push. + pub async fn change(&self, id: &SharedId, mut change: impl FnMut(&mut D) -> Result) -> Result { + let mut online = false; + if self.sync || !self.has_copy(id) { + online = self.push_pending(id, false).await? && self.download(id, false).await?; + } + if online { + for _ in 0..3 { + let mut document = self.cached(id)?; + let before = document.to_yaml()?; + let revision = self.copy_state(id)?.unwrap_or_default().revision; + let result = change(&mut document)?; + if document.to_yaml()? == before { + return Ok(result); + } + match self.upload(id, &document, Condition::Revision(revision)).await? { + Upload::Done(revision) => { + self.keep(id, &document, CopyState { revision, pending: false })?; + return Ok(result); + } + // Someone else was first: their version, then the change again. + Upload::Conflict => { + self.download(id, true).await?; + } + Upload::Offline => break, + } + } + } + let mut document = self.cached(id)?; + let before = document.to_yaml()?; + let result = change(&mut document)?; + if document.to_yaml()? == before { + return Ok(result); + } + let revision = self.copy_state(id)?.unwrap_or_default().revision; + self.keep(id, &document, CopyState { revision, pending: true })?; + if !self.sync { + println!("Changed this computer's copy of '{}'; 'cfcli {} push' uploads it", id, D::COMMAND); + } + Ok(result) + } + + /// Create the document on the server. Returns false when it already + /// exists. Without the server, the copy waits for a push. + pub async fn create(&self, id: &SharedId, document: &D) -> Result { + if self.has_copy(id) && self.copy_state(id)?.is_some_and(|c| c.pending) { + return Ok(false); + } + match self.upload(id, document, Condition::New).await? { + Upload::Done(revision) => self.keep(id, document, CopyState { revision, pending: false })?, + Upload::Conflict => return Ok(false), + Upload::Offline => { + self.keep(id, document, CopyState { revision: 0, pending: true })?; + println!("Created '{}' on this computer; 'cfcli {} push' uploads it", id, D::COMMAND); + } + } + Ok(true) + } + + /// Delete the document on the server, and the copy. Needs the server, + /// unless the document never got there. + pub async fn delete(&self, id: &SharedId) -> Result<()> { + let copy = self.copy_state(id)?; + if copy.is_some_and(|c| c.revision == 0) { + return self.forget(id); + } + let mut request = self.client.delete(self.url(id)?); + // With sync off, the user decided on the copy they have: don't delete + // what someone uploaded since. With sync on, the copy is just the + // last one used, so the document goes whatever its revision. + if let Some(copy) = copy.filter(|_| !self.sync) { + request = request.header(header::IF_MATCH, format!("\"{}\"", copy.revision)); + } + let response = self.send_online(request, &format!("deleting a shared {}", D::NOUN)).await?; + match response.status() { + status if status.is_success() => self.forget(id), + StatusCode::NOT_FOUND => { + self.fail_if_renamed(id).await?; + self.forget(id)?; + bail!(CliError::NotFound(format!("{} '{}' on {}", D::NOUN, id, self.host()))) + } + StatusCode::PRECONDITION_FAILED => bail!(CliError::InvalidValue(format!( + "{} '{}' changed on {} since this computer's copy; \ + 'cfcli {} pull --force {}' gets the new version", + D::NOUN, + id, + self.host(), + D::COMMAND, + id + ))), + _ => auth::answer::(&self.credentials.server, response).await.map(|_| ()), + } + } + + /// The shared documents: from the server when sync is on (and it + /// answers), else from the copies. Documents created here and not + /// uploaded are included either way. + pub async fn list(&self) -> Result> { + let server = if self.sync { self.server_list().await? } else { None }; + // After the list, which moves the copies of renamed organizations. + let copies = self.copy_states()?; + let mut listed = Vec::new(); + match server { + Some(server) => { + for item in server { + // The server's revision, which the next command gets, + // unless the copy has changes to upload. + let copy = match copies.get(&item.id.to_string()) { + Some(copy) if copy.pending => *copy, + _ => CopyState { revision: item.revision, pending: false }, + }; + listed.push(Listed { id: item.id, name: item.name, count: item.count, copy }); + } + // Copies of documents gone from the server go too, unless + // they have changes to upload. + for (key, copy) in &copies { + let Some(id) = SharedId::parse(key)? else { continue }; + if listed.iter().any(|l| l.id == id) { + continue; + } + if copy.pending { + listed.push(self.listed_copy(id, *copy)); + } else { + self.forget(&id)?; + } + } + } + None => { + for (key, copy) in &copies { + if let Some(id) = SharedId::parse(key)? { + listed.push(self.listed_copy(id, *copy)); + } + } + } + } + listed.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(listed) + } + + fn listed_copy(&self, id: SharedId, copy: CopyState) -> Listed { + let (name, count) = match self.cached(&id) { + Ok(document) => document.summary(), + Err(e) => (format!("(can't read: {:#})", e), 0), + }; + Listed { id, name, count, copy } + } + + /// Get the latest version of every shared document (or of one). Copies + /// with changes that aren't uploaded are kept, unless `force`. + pub async fn pull(&self, only: Option<&SharedId>, force: bool) -> Result<()> { + let Some(server) = self.server_list().await? else { + bail!(CliError::Connection(format!("{} can't be reached", self.host()))); + }; + let on_server: Vec = server.into_iter().map(|item| item.id).collect(); + if let Some(only) = only { + if let Some(now) = self.renamed(only)? { + bail!(renamed_error::(only, &now)); + } + if !on_server.contains(only) { + bail!(CliError::NotFound(format!("{} '{}' on {}", D::NOUN, only, self.host()))); + } + } + let mut kept = 0; + for id in on_server.iter().filter(|id| only.is_none_or(|only| *id == only)) { + let copy = self.copy_state(id)?; + if copy.is_some_and(|c| c.pending) && !force { + println!("Kept '{}': it has changes that aren't uploaded", id); + kept += 1; + continue; + } + let before = copy.map(|c| c.revision); + self.download(id, force).await?; + let after = self.copy_state(id)?.map(|c| c.revision); + if before != after { + println!("Pulled '{}' (revision {})", id, after.unwrap_or_default()); + } + } + // Copies of documents deleted on the server. + for id in self.cached_ids() { + if on_server.contains(&id) || only.is_some_and(|only| *only != id) { + continue; + } + let pending = self.copy_state(&id)?.is_some_and(|c| c.pending); + if pending && !force { + println!("Kept '{}': it is gone from {}, but has changes that aren't uploaded", id, self.host()); + kept += 1; + } else { + self.forget(&id)?; + println!("Removed '{}': it is gone from {}", id, self.host()); + } + } + if kept > 0 { + println!( + "Upload kept changes with 'cfcli {} push', or drop them with 'pull --force'", + D::COMMAND + ); + } + Ok(()) + } + + /// Upload the changes made while sync was off or the server was out of + /// reach. `force` overwrites what others uploaded in between. + pub async fn push(&self, only: Option<&SharedId>, force: bool) -> Result<()> { + // Copies of organizations with a new ID move to it first. + self.server_list().await?; + if let Some(only) = only { + if let Some(now) = self.renamed(only)? { + bail!(renamed_error::(only, &now)); + } + } + let pending: Vec = self + .copy_states()? + .iter() + .filter(|(_, copy)| copy.pending) + .filter_map(|(id, _)| SharedId::parse(id).ok().flatten()) + .filter(|id| only.is_none_or(|only| id == only)) + .collect(); + if pending.is_empty() { + println!("Nothing to push"); + return Ok(()); + } + let mut failed = Vec::new(); + for id in &pending { + match self.push_pending(id, force).await { + Ok(true) => println!( + "Pushed '{}' (revision {})", + id, + self.copy_state(id)?.unwrap_or_default().revision + ), + Ok(false) => bail!(CliError::Connection(format!("{} can't be reached", self.host()))), + Err(e) => { + match e.downcast_ref::() { + Some(CliError::InvalidValue(message)) => println!("{}", message), + _ => println!("{:#}", e), + } + failed.push(id.to_string()); + } + } + } + if !failed.is_empty() { + bail!(CliError::InvalidValue(format!("not pushed: {}", failed.join(", ")))); + } + Ok(()) + } +} + +fn renamed_error(id: &SharedId, now: &SharedId) -> CliError { + CliError::NotFound(format!( + "{} '{}' (your organization '{}' is now called '{}'; use '{}')", + D::NOUN, + id, + id.org, + now.org, + now + )) +} + +/// Select the swarm under its organization's new ID, if one of `renames` +/// (old, new) changed the selected swarm's. +fn select_renamed(renames: &[(String, String)]) { + let Ok(mut config) = confy::load::("cf-cli", None) else { + return; + }; + let Some(selected) = config.swarm.as_deref().and_then(|id| SharedId::parse(id).ok().flatten()) else { + return; + }; + if let Some((_, new)) = renames.iter().find(|(old, _)| *old == selected.org) { + let now = SharedId { org: new.clone(), name: selected.name }; + config.swarm = Some(now.to_string()); + if confy::store("cf-cli", None, config).is_ok() { + println!("The selected swarm is '{}' now", now); + } + } +} + +fn read_state(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(text) => serde_json::from_str(&text).with_context(|| format!("reading {}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(State::default()), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } +} + +/// cfcli kept the copies of shared swarms in `synced///` +/// before there were other kinds of documents; move them to +/// `synced//swarms//`. +fn move_old_swarm_copies(dir: &Path) { + let swarms = dir.join("swarms"); + if swarms.exists() { + return; + } + let Ok(mut state) = read_state(&dir.join("state.json")) else { + return; + }; + let orgs: std::collections::BTreeSet = state + .remove("swarms") + .unwrap_or_default() + .keys() + .filter_map(|id| id.split_once('/').map(|(org, _)| org.to_string())) + .collect(); + for org in orgs { + let old = dir.join(&org); + if old.is_dir() && std::fs::create_dir_all(&swarms).is_ok() { + let _ = std::fs::rename(&old, swarms.join(&org)); + } + } +} + +/// The revision in a response's ETag, `"N"`. +fn etag_revision(response: &Response) -> Result { + response + .headers() + .get(header::ETAG) + .and_then(|etag| etag.to_str().ok()) + .and_then(|etag| etag.trim_start_matches("W/").trim_matches('"').parse().ok()) + .context("the server sent a document without its revision") +} + +// ---- Both behind one kind of ID ---- + +/// Local and shared documents of one kind behind one kind of ID: `` is +/// a file in the local folder, `/` a document on the server. +pub struct Documents { + pub local: Store, + /// None when cfcli isn't signed in. + pub shared: Option>, +} + +impl Documents { + pub fn open(config: &Config) -> Result { + Ok(Documents { local: Store::open()?, shared: Shared::open(config.sync_on())? }) + } + + /// For shell completion: only [`Documents::cached`] and + /// [`Documents::cached_ids`] are used, which never ask the server. + pub fn open_cached() -> Result { + Ok(Documents { local: Store::open()?, shared: Shared::open(false)? }) + } + + pub fn shared(&self, id: &SharedId) -> Result<&Shared> { + self.shared.as_ref().ok_or_else(|| { + CliError::NotFound(format!( + "a sign-in: '{}' is a shared {}, sign in with 'cfcli auth login'", + id, + D::NOUN + )) + .into() + }) + } + + pub async fn load(&self, id: &str) -> Result { + match SharedId::parse(id)? { + None => self.local.load(id), + Some(shared) => self.shared(&shared)?.load(&shared).await, + } + } + + /// The document without asking the server (a shared one's copy), for + /// shell completion and pickers. + pub fn cached(&self, id: &str) -> Result { + match SharedId::parse(id)? { + None => self.local.load(id), + Some(shared) => self.shared(&shared)?.cached(&shared), + } + } + + /// Change a document. `change` may run more than once for a shared one + /// (see [`Shared::change`]), so it must not ask the user anything. + pub async fn change(&self, id: &str, mut change: impl FnMut(&mut D) -> Result) -> Result { + match SharedId::parse(id)? { + None => { + let mut document = self.local.load(id)?; + let before = document.to_yaml()?; + let result = change(&mut document)?; + if document.to_yaml()? != before { + self.local.save(id, &document)?; + } + Ok(result) + } + Some(shared) => self.shared(&shared)?.change(&shared, change).await, + } + } + + /// Create a document. A shared one is created on the server. + pub async fn create(&self, id: &str, document: &D) -> Result<()> { + match SharedId::parse(id)? { + None => { + check_id::(id)?; + if self.local.exists(id) { + bail!(CliError::InvalidValue(format!("{} '{}' already exists", D::NOUN, id))); + } + self.local.save(id, document) + } + Some(shared_id) => { + let shared = self.shared(&shared_id)?; + if !shared.create(&shared_id, document).await? { + bail!(CliError::InvalidValue(format!( + "{} '{}' already exists on {}", + D::NOUN, + id, + shared.host() + ))); + } + Ok(()) + } + } + } + + /// Delete a document. A shared one is deleted on the server, for everyone. + pub async fn delete(&self, id: &str) -> Result<()> { + match SharedId::parse(id)? { + None => self.local.delete(id), + Some(shared) => self.shared(&shared)?.delete(&shared).await, + } + } + + /// Local documents and the shared ones there are copies of, without + /// asking the server. + pub fn cached_ids(&self) -> Result> { + let mut ids = self.local.ids()?; + if let Some(shared) = &self.shared { + ids.extend(shared.cached_ids().into_iter().map(|id| id.to_string())); + } + Ok(ids) + } + + /// Where a document is kept, for the user. + pub fn place(&self, id: &str) -> String { + match (&self.shared, SharedId::parse(id)) { + (Some(shared), Ok(Some(_))) => shared.host().to_string(), + _ => "this computer".to_string(), + } + } + + /// The server for `pull`/`push`, and the one shared document to sync if + /// given. + pub fn for_sync(&self, id: Option<&str>) -> Result<(&Shared, Option)> { + let Some(shared) = &self.shared else { + bail!(CliError::NotFound(format!( + "a sign-in: shared {}s need 'cfcli auth login'", + D::NOUN + ))); + }; + let only = match id { + None => None, + Some(id) => Some(SharedId::parse(id)?.ok_or_else(|| { + CliError::InvalidValue(format!( + "'{}' is a local {}; only shared ones (/) are pulled and pushed", + id, + D::NOUN + )) + })?), + }; + Ok((shared, only)) + } +} + +/// A document as `list` and the pickers show it. +pub struct Entry { + pub id: String, + pub name: String, + /// The item count, "?" when the file can't be read. + pub count: String, + /// Where it is kept: "this computer" or the server. + pub place: String, + pub revision: Option, + pub pending: bool, +} + +impl Entry { + pub fn stored(&self) -> String { + match self.revision { + None => self.place.clone(), + Some(0) => format!("{}, not uploaded yet", self.place), + Some(revision) if self.pending => format!("{}, revision {}, changes not pushed", self.place, revision), + Some(revision) => format!("{}, revision {}", self.place, revision), + } + } +} + +impl Documents { + /// The local documents, then the shared ones. + pub async fn entries(&self) -> Result> { + let mut entries: Vec = self + .local + .ids()? + .into_iter() + .map(|id| { + let (name, count) = match self.local.load(&id) { + Ok(document) => { + let (name, count) = document.summary(); + (name, count.to_string()) + } + Err(e) => (format!("(can't read: {:#})", e), "?".to_string()), + }; + Entry { id, name, count, place: "this computer".to_string(), revision: None, pending: false } + }) + .collect(); + if let Some(shared) = &self.shared { + for listed in shared.list().await? { + entries.push(Entry { + id: listed.id.to_string(), + name: listed.name, + count: listed.count.to_string(), + place: shared.host().to_string(), + revision: Some(listed.copy.revision), + pending: listed.copy.pending, + }); + } + } + Ok(entries) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_shared_and_local_ids() { + assert_eq!(SharedId::parse("lab").unwrap(), None); + let id = SharedId::parse("bitcraze-lab/cage").unwrap().unwrap(); + assert_eq!((id.org.as_str(), id.name.as_str()), ("bitcraze-lab", "cage")); + assert_eq!(id.to_string(), "bitcraze-lab/cage"); + for bad in ["Lab/cage", "/cage", "lab/", "lab/a/b", "lab/.x", "la b/x"] { + assert!(SharedId::parse(bad).is_err(), "{}", bad); + } + } + + #[test] + fn moves_old_swarm_copies() { + let dir = std::env::temp_dir().join(format!("cfcli-docs-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("lab")).unwrap(); + std::fs::write(dir.join("lab").join("cage.yaml"), "name: x\nunits: []\n").unwrap(); + std::fs::write(dir.join("state.json"), r#"{"swarms": {"lab/cage": {"revision": 3, "pending": true}}}"#).unwrap(); + move_old_swarm_copies(&dir); + assert!(dir.join("swarms").join("lab").join("cage.yaml").is_file()); + assert!(!dir.join("lab").exists()); + // The state file has the same shape as before. + let state = read_state(&dir.join("state.json")).unwrap(); + assert_eq!(state["swarms"]["lab/cage"].revision, 3); + // Only once. + std::fs::create_dir_all(dir.join("lab")).unwrap(); + move_old_swarm_copies(&dir); + assert!(dir.join("lab").exists()); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/src/modules/lighthouse/configs.rs b/src/modules/lighthouse/configs.rs new file mode 100644 index 0000000..2e55537 --- /dev/null +++ b/src/modules/lighthouse/configs.rs @@ -0,0 +1,657 @@ +//! Stored lighthouse configurations: `lh config list`, `save`, `import`, +//! `export`, `name`, `delete`, `move`, `pull` and `push`, and the +//! configuration that `write` and `check` use. +//! +//! Like swarms, local configurations are files in a `lighthouse` folder next +//! to the cfcli config, named ``, and shared ones are on the server, +//! named `/` (see [`crate::modules::documents`]). The files are +//! the Crazyflie client's format, so `export` gives a file it opens. A file +//! may also have a `name`, which the client ignores. + +use anyhow::{anyhow, bail, Context, Result}; +use crazyflie_lib::Crazyflie; +use inquire::{Confirm, Select}; +use tabled::Tabled; + +use super::{compare, describe_distance, CalibrationDelta, LighthouseConfigFile, Part}; +use crate::error::CliError; +use crate::modules::documents::{Document, Documents, Entry, SharedId}; +use crate::modules::swarm::Swarms; +use crate::utils::display::{csv_row, print_table, table}; +use crate::Config; + +impl Document for LighthouseConfigFile { + const FOLDER: &'static str = "lighthouse"; + const NOUN: &'static str = "lighthouse config"; + const COMMAND: &'static str = "lh config"; + const SERVER_COUNT: &'static str = "base_stations"; + + fn from_yaml(yaml: &str) -> Result { + LighthouseConfigFile::from_yaml(yaml) + } + + fn to_yaml(&self) -> Result { + LighthouseConfigFile::to_yaml(self) + } + + /// The base stations with a position: a Crazyflie also keeps the + /// calibration of base stations it has seen elsewhere. + fn summary(&self) -> (String, usize) { + (self.name().unwrap_or_default().to_string(), self.geos.len()) + } +} + +impl LighthouseConfigFile { + /// The `name` in the file, if it has one. + pub fn name(&self) -> Option<&str> { + self.extra.get("name").and_then(|v| v.as_str()).filter(|name| !name.trim().is_empty()) + } + + pub fn set_name(&mut self, name: &str) { + self.extra.insert("name".into(), name.into()); + } +} + +/// Local and shared lighthouse configurations. +pub(crate) type LhConfigs = Documents; + +fn base_stations(n: usize) -> String { + match n { + 1 => "1 base station".to_string(), + n => format!("{} base stations", n), + } +} + +/// A stored configuration for the user: `'lab/cage' (revision 3)`, and +/// whether this computer's copy has changes the server doesn't have. +pub(crate) fn describe(configs: &LhConfigs, id: &str) -> String { + let copy = SharedId::parse(id) + .ok() + .flatten() + .and_then(|shared| configs.shared(&shared).ok()?.copy_state(&shared).ok()?); + match copy { + Some(copy) if copy.revision == 0 => format!("'{}' (not uploaded yet)", id), + Some(copy) if copy.pending => format!("'{}' (revision {}, changes not pushed)", id, copy.revision), + Some(copy) => format!("'{}' (revision {})", id, copy.revision), + None => format!("'{}'", id), + } +} + +// ---- Which configuration a command uses ---- + +/// The configuration for `write` and `check`, and how to name it: a stored +/// one (`id`), a file (`input`), what is piped in, or else one the user +/// picks (when `pick`, starting at the selected swarm's) or the one the +/// selected swarm names (also when stdin is empty, as in a script). +pub async fn source( + config: &Config, + id: Option<&str>, + input: Option<&str>, + pick: bool, +) -> Result<(LighthouseConfigFile, String)> { + if let Some(id) = id { + let configs = LhConfigs::open(config)?; + return Ok((configs.load(id).await?, describe(&configs, id))); + } + if let Some(path) = input { + return Ok((super::load(path)?, path.to_string())); + } + if let Some(file) = super::load_piped()? { + return Ok((file, "the configuration from stdin".to_string())); + } + if pick { + let configs = LhConfigs::open(config)?; + let entries = configs.entries().await?; + if entries.is_empty() { + bail!(CliError::NotFound( + "lighthouse configs; store one with 'cfcli lh config save ' or \ + 'cfcli lh config import ', or give a file with -i" + .to_string() + )); + } + // A swarm that can't be loaded just leaves the cursor at the top. + let current = selected_swarm_config(config).await.ok().flatten(); + let id = pick_entry(&entries, "Lighthouse config to write:", current.as_deref())?; + return Ok((configs.load(&id).await?, describe(&configs, &id))); + } + match selected_swarm_config(config).await? { + Some(id) => { + let configs = LhConfigs::open(config)?; + let file = configs.load(&id).await?; + let label = describe(&configs, &id); + println!("Using lighthouse config {}, which the selected swarm flies in", label); + Ok((file, label)) + } + None => bail!(CliError::MissingArg( + "a lighthouse configuration: give a stored one's ID, a file with -i, or pipe one in \ + (the selected swarm names none)" + .to_string() + )), + } +} + +/// The lighthouse configuration the selected swarm names. +async fn selected_swarm_config(config: &Config) -> Result> { + match &config.swarm { + Some(swarm) => Ok(Swarms::open(config)?.load(swarm).await?.lighthouse), + None => Ok(None), + } +} + +// ---- Listing and showing ---- + +/// One row of `lh config list`. +#[derive(Tabled)] +struct ConfigRow { + #[tabled(rename = "ID")] + id: String, + #[tabled(rename = "Name")] + name: String, + #[tabled(rename = "Base stations")] + count: String, + #[tabled(rename = "Stored")] + stored: String, +} + +pub async fn list(configs: &LhConfigs, csv: bool) -> Result<()> { + let entries = configs.entries().await?; + if csv { + csv_row(&["id", "name", "base_stations", "stored", "revision", "not_pushed"]); + for entry in &entries { + let revision = entry.revision.map(|r| r.to_string()).unwrap_or_default(); + csv_row(&[ + &entry.id, + &entry.name, + &entry.count, + &entry.place, + &revision, + if entry.pending { "yes" } else { "no" }, + ]); + } + } else if entries.is_empty() { + println!( + "No lighthouse configs yet. Store a Crazyflie's with 'cfcli lh config save ', or \ + import a file from the Crazyflie client with 'cfcli lh config import '." + ); + } else { + let rows: Vec = entries + .iter() + .map(|entry| ConfigRow { + id: entry.id.clone(), + name: entry.name.clone(), + count: entry.count.clone(), + stored: entry.stored(), + }) + .collect(); + print_table(&table(&rows)); + if configs.shared.as_ref().is_some_and(|shared| !shared.sync()) && entries.iter().any(|e| e.revision.is_some()) { + println!("Sync is off: shared configs are this computer's copies ('cfcli lh config pull' gets the latest)"); + } + } + Ok(()) +} + +/// `lh config display ` +pub async fn show(configs: &LhConfigs, id: &str, csv: bool) -> Result<()> { + let file = configs.load(id).await?; + let title = match file.name() { + Some(name) => format!("Lighthouse Configuration {}: {}", describe(configs, id), name), + None => format!("Lighthouse Configuration {}", describe(configs, id)), + }; + super::print_config(&file, &title, csv); + Ok(()) +} + +/// Let the user pick a stored configuration. +async fn pick(configs: &LhConfigs, message: &str) -> Result { + let entries = configs.entries().await?; + if entries.is_empty() { + bail!(CliError::NotFound( + "lighthouse configs; store one with 'cfcli lh config save '".to_string() + )); + } + pick_entry(&entries, message, None) +} + +/// Let the user pick one of `entries`, starting at `current`. +pub(crate) fn pick_entry(entries: &[Entry], message: &str, current: Option<&str>) -> Result { + let labels: Vec = entries + .iter() + .map(|e| match e.name.is_empty() { + true => format!("{} ({} base stations)", e.id, e.count), + false => format!("{} - {} ({} base stations)", e.id, e.name, e.count), + }) + .collect(); + let start = current.and_then(|current| entries.iter().position(|e| e.id == current)).unwrap_or(0); + let picked = Select::new(message, labels) + .with_starting_cursor(start) + .raw_prompt() + .map_err(|_| anyhow!("No lighthouse config selected"))?; + Ok(entries[picked.index].id.clone()) +} + +// ---- Storing ---- + +/// What changes for each base station when `old` becomes `new`. +pub fn changes(old: &LighthouseConfigFile, new: &LighthouseConfigFile) -> Vec { + let mut changes = Vec::new(); + for diff in compare(new, old) { + let bs = format!("BS {} (channel {})", diff.id, diff.id + 1); + match diff.geometry { + Part::Differs(d) => changes.push(format!( + "{}: moved {}, turned {:.2}°", + bs, + describe_distance(d.moved_m), + d.turned_deg + )), + Part::OnlyInFile => changes.push(format!("{}: position added", bs)), + Part::OnlyOnCf => changes.push(format!("{}: position removed", bs)), + Part::Same | Part::Absent => {} + } + match diff.calibration { + Part::Differs(CalibrationDelta::Replaced { file_uid, cf_uid }) => changes.push(format!( + "{}: another base station, 0x{:08X} instead of 0x{:08X}", + bs, file_uid, cf_uid + )), + Part::Differs(CalibrationDelta::Values { .. }) => changes.push(format!("{}: calibration changed", bs)), + Part::OnlyInFile => changes.push(format!("{}: calibration added", bs)), + Part::OnlyOnCf => changes.push(format!("{}: calibration removed", bs)), + Part::Same | Part::Absent => {} + } + } + if old.name() != new.name() { + changes.push(format!("name: {}", new.name().unwrap_or("(none)"))); + } + changes +} + +/// Store `new` as `id`: create it, or replace what is there after showing +/// what changes and asking (unless `force`). A shared configuration keeps +/// the old one as an earlier revision. +async fn store(configs: &LhConfigs, id: &str, mut new: LighthouseConfigFile, force: bool, non_interactive: bool) -> Result<()> { + if new.geos.is_empty() { + bail!(CliError::InvalidValue( + "the configuration has no base station positions (geometry); estimate it first, \ + e.g. in the Crazyflie client's Lighthouse tab" + .to_string() + )); + } + let existing = match configs.load(id).await { + Ok(old) => Some(old), + Err(e) if matches!(e.downcast_ref::(), Some(CliError::NotFound(_))) => None, + Err(e) => return Err(e), + }; + let Some(old) = existing else { + configs.create(id, &new).await?; + println!( + "Stored lighthouse config {} on {}: {}", + describe(configs, id), + configs.place(id), + base_stations(new.geos.len()) + ); + return Ok(()); + }; + + // What the new configuration doesn't say, like its name, stays. + for (key, value) in &old.extra { + if !new.extra.contains_key(key) { + new.extra.insert(key.clone(), value.clone()); + } + } + let changes = changes(&old, &new); + if changes.is_empty() { + println!("Lighthouse config {} is already this configuration", describe(configs, id)); + return Ok(()); + } + println!("Changes to lighthouse config {}:", describe(configs, id)); + for change in &changes { + println!(" {}", change); + } + if !force { + crate::require_arg(non_interactive, "--force")?; + let question = match SharedId::parse(id)? { + Some(shared) => format!("Update '{}' on {}, for everyone in {}?", id, configs.place(id), shared.org), + None => format!("Replace '{}'?", id), + }; + if !Confirm::new(&question).with_default(false).prompt().unwrap_or(false) { + println!("Nothing changed"); + return Ok(()); + } + } + configs + .change(id, |file| { + *file = new.clone(); + Ok(()) + }) + .await?; + println!("Updated lighthouse config {}", describe(configs, id)); + Ok(()) +} + +/// `lh config save `: the Crazyflie's configuration. +pub async fn save( + configs: &LhConfigs, + cf: &Crazyflie, + id: &str, + name: Option<&str>, + force: bool, + non_interactive: bool, +) -> Result<()> { + check_new_id(configs, id)?; + let mut file = super::read_with_progress(cf, non_interactive).await?; + if let Some(name) = name { + file.set_name(name); + } + store(configs, id, file, force, non_interactive).await +} + +/// `lh config import `: a file from the Crazyflie client (or `read`). +pub async fn import( + configs: &LhConfigs, + path: &str, + id: Option<&str>, + name: Option<&str>, + force: bool, + non_interactive: bool, +) -> Result<()> { + let id = match id { + Some(id) => id.to_string(), + None => std::path::Path::new(path) + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_default(), + }; + check_new_id(configs, &id).with_context(|| format!("importing {} (pick another ID with --id)", path))?; + let mut file = super::load(path)?; + if let Some(name) = name { + file.set_name(name); + } + store(configs, &id, file, force, non_interactive).await +} + +/// An ID to store under: a valid file name, and for a shared one, a sign-in. +fn check_new_id(configs: &LhConfigs, id: &str) -> Result<()> { + match SharedId::parse(id)? { + None => crate::modules::documents::check_id::(id), + Some(shared) => configs.shared(&shared).map(|_| ()), + } +} + +/// `lh config export `: the file, for the Crazyflie client. +pub async fn export(configs: &LhConfigs, id: &str, output: Option<&str>) -> Result<()> { + let yaml = configs.load(id).await?.to_yaml()?; + match output { + Some(path) => { + std::fs::write(path, yaml).with_context(|| format!("writing {}", path))?; + println!("Exported lighthouse config {} to {}", describe(configs, id), path); + } + None => print!("{}", yaml), + } + Ok(()) +} + +/// `lh config name [NAME]`: show the name a configuration is shown +/// with, or set it. The ID stays the same. +pub async fn name(configs: &LhConfigs, id: &str, name: Option<&str>) -> Result<()> { + let Some(name) = name else { + match configs.load(id).await?.name() { + Some(name) => println!("{}", name), + None => eprintln!( + "Lighthouse config '{}' has no name; give it one with 'cfcli lh config name {} '", + id, id + ), + } + return Ok(()); + }; + let name = name.trim(); + if name.is_empty() { + bail!(CliError::InvalidValue("a lighthouse config's name can't be empty".to_string())); + } + configs + .change(id, |file| { + file.set_name(name); + Ok(()) + }) + .await?; + println!("Lighthouse config {} is named '{}' now", describe(configs, id), name); + Ok(()) +} + +// ---- Deleting and moving ---- + +/// Whether swarm `swarm` may name lighthouse config `config`: a local swarm +/// one on this computer, a shared swarm one in its organization, which +/// everyone who uses the swarm can read. +pub(crate) fn may_name(swarm: &str, config: &str) -> bool { + match (SharedId::parse(swarm), SharedId::parse(config)) { + (Ok(None), Ok(None)) => true, + (Ok(Some(swarm)), Ok(Some(config))) => swarm.org == config.org, + _ => false, + } +} + +/// The swarms that fly in lighthouse config `id`: the local swarms, and the +/// shared swarms the server lists (only a shared config can be named by +/// them), or this computer's copies of them when it can't be reached. +async fn swarms_naming(configs: &LhConfigs, id: &str) -> Vec { + let names = |swarm: &crate::modules::swarm::store::Swarm| swarm.lighthouse.as_deref() == Some(id); + let Ok(swarms) = Swarms::open_cached() else { + return Vec::new(); + }; + let mut naming: Vec = swarms + .local + .ids() + .unwrap_or_default() + .into_iter() + .filter(|swarm| swarms.local.load(swarm).is_ok_and(|s| names(&s))) + .collect(); + let Ok(Some(shared)) = SharedId::parse(id) else { + return naming; + }; + let listed = match configs.shared(&shared) { + Ok(server) => server.related(&shared, "swarms").await.ok().flatten(), + Err(_) => None, + }; + match listed { + Some(listed) => naming.extend( + listed + .iter() + .filter_map(|swarm| swarm.get("id").and_then(|id| id.as_str()).map(str::to_string)), + ), + None => { + if let Some(copies) = &swarms.shared { + for swarm in copies.cached_ids() { + if copies.cached(&swarm).is_ok_and(|s| names(&s)) { + naming.push(swarm.to_string()); + } + } + } + } + } + naming +} + +fn quoted(ids: &[String]) -> String { + ids.iter().map(|id| format!("'{}'", id)).collect::>().join(", ") +} + +/// "names" and "it" for one swarm, "name" and "them" for more. +fn verb_and_pronoun(ids: &[String]) -> (&'static str, &'static str) { + if ids.len() == 1 { ("names", "it") } else { ("name", "them") } +} + +pub async fn delete(configs: &LhConfigs, id: Option<&str>, non_interactive: bool) -> Result<()> { + let id = match id { + Some(id) => id.to_string(), + None => { + crate::require_arg(non_interactive, "")?; + pick(configs, "Select the lighthouse config to delete:").await? + } + }; + let naming = swarms_naming(configs, &id).await; + if !naming.is_empty() { + println!("Swarms that fly in lighthouse config '{}': {}", id, quoted(&naming)); + } + // Deleting can't be undone, so ask whenever there is someone to ask. + if !non_interactive { + let question = match SharedId::parse(&id)? { + Some(shared) => format!( + "Delete lighthouse config '{}' on {}, for everyone in {}?", + id, + configs.place(&id), + shared.org + ), + None => format!("Delete lighthouse config '{}'?", id), + }; + if !Confirm::new(&question).with_default(false).prompt().unwrap_or(false) { + println!("Nothing deleted"); + return Ok(()); + } + } + configs.delete(&id).await?; + println!("Deleted lighthouse config '{}'", id); + if !naming.is_empty() { + let (names, them) = verb_and_pronoun(&naming); + println!( + "{} still {} it: give {} another with 'cfcli swarm config lh --swarm ', \ + or none with '--clear'", + quoted(&naming), + names, + them + ); + } + Ok(()) +} + +/// `lh config move`: share one (cage -> org/cage), take it back +/// (org/cage -> cage), or rename it. +pub async fn move_config(configs: &LhConfigs, from: &str, to: &str, non_interactive: bool) -> Result<()> { + if from == to { + bail!(CliError::InvalidValue(format!("'{}' is already where it is", from))); + } + check_new_id(configs, to)?; + let file = configs.load(from).await?; + if SharedId::parse(to)?.is_none() && configs.local.exists(to) { + bail!(CliError::InvalidValue(format!("lighthouse config '{}' already exists", to))); + } + let naming = swarms_naming(configs, from).await; + // Moving a shared configuration away deletes it for everyone: ask. + if let Some(shared) = SharedId::parse(from)? { + if !naming.is_empty() { + println!("Swarms that fly in lighthouse config '{}': {}", from, quoted(&naming)); + } + if !non_interactive { + let question = format!( + "Move '{}' to '{}'? It is deleted on {} for everyone in {}.", + from, + to, + configs.place(from), + shared.org + ); + if !Confirm::new(&question).with_default(false).prompt().unwrap_or(false) { + println!("Nothing moved"); + return Ok(()); + } + } + } + configs.create(to, &file).await?; + if let Err(e) = configs.delete(from).await { + bail!("copied '{}' to '{}', but '{}' is still there: {:#}", from, to, from, e); + } + println!( + "Moved lighthouse config '{}' from {} to '{}' on {}", + from, + configs.place(from), + to, + configs.place(to) + ); + if !naming.is_empty() { + let (can, cannot): (Vec, Vec) = naming.into_iter().partition(|swarm| may_name(swarm, to)); + if !can.is_empty() { + let (names, them) = verb_and_pronoun(&can); + println!( + "{} still {} '{}': 'cfcli swarm config lh {} --swarm ' gives {} '{}'", + quoted(&can), + names, + from, + to, + them, + to + ); + } + if !cannot.is_empty() { + let (names, _) = verb_and_pronoun(&cannot); + println!( + "{} still {} '{}', and can't name '{}': a local swarm names lighthouse configs on this \ + computer, a shared swarm those in its organization", + quoted(&cannot), + names, + from, + to + ); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FILE: &str = "\ +name: The cage +type: lighthouse_system_configuration +version: '1' +systemType: 2 +geos: + 0: + origin: [0.0, 0.0, 2.0] + rotation: [[1, 0, 0], [0, 1, 0], [0, 0, 1]] +calibs: + 0: + uid: 2360210604 + sweeps: + - {phase: 0.0, tilt: -0.051, curve: 0.275, gibmag: -0.005, gibphase: 2.281, ogeemag: -0.184, ogeephase: 1.847} + - {phase: -0.004, tilt: 0.047, curve: 0.367, gibmag: -0.005, gibphase: 2.548, ogeemag: -0.124, ogeephase: 2.051} +"; + + #[test] + fn swarms_name_configs_of_their_own_kind() { + assert!(may_name("bench", "cage")); + assert!(may_name("lab/flight", "lab/cage")); + assert!(!may_name("bench", "lab/cage")); + assert!(!may_name("lab/flight", "cage")); + assert!(!may_name("lab/flight", "other/room")); + } + + #[test] + fn name_and_summary() { + let mut file = LighthouseConfigFile::from_yaml(FILE).unwrap(); + assert_eq!(file.name(), Some("The cage")); + assert_eq!(file.summary(), ("The cage".to_string(), 1)); + file.set_name("Fish tank"); + assert!(file.to_yaml().unwrap().contains("name: Fish tank")); + // A file without positions counts no base stations. + file.geos.clear(); + assert_eq!(file.summary().1, 0); + } + + #[test] + fn describes_changes() { + let old = LighthouseConfigFile::from_yaml(FILE).unwrap(); + assert!(changes(&old, &old).is_empty()); + let mut new = old.clone(); + new.geos.get_mut(&0).unwrap().origin[0] = 0.05; + new.calibs.get_mut(&0).unwrap().uid = 1; + new.geos.insert(3, new.geos[&0].clone()); + let changes = changes(&old, &new); + assert_eq!( + changes, + [ + "BS 0 (channel 1): moved 5.0 cm, turned 0.00°", + "BS 0 (channel 1): another base station, 0x00000001 instead of 0x8CADF4AC", + "BS 3 (channel 4): position added", + ] + ); + } +} diff --git a/src/modules/lighthouse.rs b/src/modules/lighthouse/mod.rs similarity index 96% rename from src/modules/lighthouse.rs rename to src/modules/lighthouse/mod.rs index 67f2436..a51843d 100644 --- a/src/modules/lighthouse.rs +++ b/src/modules/lighthouse/mod.rs @@ -20,6 +20,8 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::error::CliError; use crate::utils::display::csv_row; +pub mod configs; + /// The `type` of a lighthouse configuration file. pub const FILE_TYPE: &str = "lighthouse_system_configuration"; /// The file version written. cflib reads only this one. @@ -416,7 +418,7 @@ fn calibration_delta(file: &CalibrationFileEntry, cf: &CalibrationFileEntry) -> } } -fn describe_distance(m: f64) -> String { +pub(crate) fn describe_distance(m: f64) -> String { if m < 0.01 { format!("{:.1} mm", m * 1000.0) } else { @@ -624,25 +626,33 @@ pub async fn write_config( // ---- Commands ---- -/// Read a configuration file, or stdin when no path is given. -pub fn load(file_path: Option<&str>) -> Result { - let yaml = match file_path { - Some(path) => std::fs::read_to_string(path) - .with_context(|| format!("Failed to read lighthouse config file: {}", path))?, - None => { - use std::io::Read; - let mut buf = String::new(); - std::io::stdin() - .read_to_string(&mut buf) - .context("Failed to read lighthouse config from stdin")?; - buf - } - }; +/// Read a configuration file. +pub fn load(path: &str) -> Result { + let yaml = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read lighthouse config file: {}", path))?; + LighthouseConfigFile::from_yaml(&yaml).with_context(|| format!("Failed to load {}", path)) +} + +/// The configuration piped in, or None when nothing is: stdin is a +/// terminal, or empty, as for a command run from a script or cron. +pub fn load_piped() -> Result> { + use std::io::{IsTerminal, Read}; + if std::io::stdin().is_terminal() { + return Ok(None); + } + let mut yaml = String::new(); + std::io::stdin() + .read_to_string(&mut yaml) + .context("Failed to read lighthouse config from stdin")?; + if yaml.trim().is_empty() { + return Ok(None); + } LighthouseConfigFile::from_yaml(&yaml) - .with_context(|| format!("Failed to load {}", file_path.unwrap_or("the configuration from stdin"))) + .context("Failed to load the configuration from stdin") + .map(Some) } -async fn read_with_progress(cf: &Crazyflie, non_interactive: bool) -> Result { +pub(crate) async fn read_with_progress(cf: &Crazyflie, non_interactive: bool) -> Result { let count = supported_base_stations(cf).unwrap_or(MAX_BASE_STATIONS); let progress_bar = make_progress(2 * count as usize, "Reading", non_interactive); let pb = progress_bar.clone(); @@ -654,18 +664,18 @@ async fn read_with_progress(cf: &Crazyflie, non_interactive: bool) -> Result Result<()> { let config = read_with_progress(cf, non_interactive).await?; - print_config(&config, None, csv); + print_config(&config, "Lighthouse Configuration", csv); Ok(()) } /// Display lighthouse configuration from a YAML file (no connection needed) pub fn display_file(file_path: &str, csv: bool) -> Result<()> { - let config = load(Some(file_path))?; - print_config(&config, Some(file_path), csv); + let config = load(file_path)?; + print_config(&config, &format!("Lighthouse Configuration File: {}", file_path), csv); Ok(()) } -fn print_config(config: &LighthouseConfigFile, file_path: Option<&str>, csv: bool) { +pub(crate) fn print_config(config: &LighthouseConfigFile, title: &str, csv: bool) { if csv { csv_row(&["section", "bs_id", "key", "value"]); for (id, geo) in &config.geos { @@ -677,10 +687,6 @@ fn print_config(config: &LighthouseConfigFile, file_path: Option<&str>, csv: boo return; } - let title = match file_path { - Some(path) => format!("Lighthouse Configuration File: {}", path), - None => "Lighthouse Configuration".to_string(), - }; println!("{}", title); println!("{}", "=".repeat(title.chars().count())); println!(); diff --git a/src/modules/settings.rs b/src/modules/settings.rs index 813d543..a5c342e 100644 --- a/src/modules/settings.rs +++ b/src/modules/settings.rs @@ -1,5 +1,7 @@ use anyhow::Result; use crate::{Config, console_preserve_path, decode_address}; +use crate::modules::documents; +use crate::modules::lighthouse::LighthouseConfigFile; use crate::modules::swarm::store::Store; pub fn show(config: &Config) { @@ -15,6 +17,9 @@ pub fn show(config: &Config) { if let Ok(store) = Store::open() { println!("Swarm folder: {}", store.dir().display()); } + if let Ok(store) = documents::Store::::open() { + println!("Lighthouse config folder: {}", store.dir().display()); + } } pub fn timeout_show(config: &Config) { @@ -88,7 +93,7 @@ pub fn address_clear(config: &mut Config) { pub fn sync_show(config: &Config) { println!( - "Sync shared swarms: {}{}", + "Sync shared swarms and lighthouse configs: {}{}", if config.sync_on() { "on" } else { "off" }, if config.sync.is_none() { " (default)" } else { "" } ); @@ -100,11 +105,14 @@ pub fn sync_set(config: &mut Config, on: bool) { println!("Could not save settings: {:?}", err); }); if on { - println!("Sync is on: swarm commands check the server first and upload changes right away"); + println!( + "Sync is on: commands check the server first for shared swarms and lighthouse configs, \ + and upload changes right away" + ); } else { println!( - "Sync is off: swarm commands use this computer's copies of shared swarms; \ - 'cfcli swarm config pull' and 'push' sync them" + "Sync is off: commands use this computer's copies of shared swarms and lighthouse configs; \ + 'cfcli swarm config pull'/'push' and 'cfcli lh config pull'/'push' sync them" ); } } diff --git a/src/modules/swarm/lighthouse.rs b/src/modules/swarm/lighthouse.rs new file mode 100644 index 0000000..0b0a640 --- /dev/null +++ b/src/modules/swarm/lighthouse.rs @@ -0,0 +1,435 @@ +//! The lighthouse configuration of a swarm: which one it flies in (`swarm +//! config lh`), and checking and writing it on its Crazyflies +//! (`swarm lh check|write`). +//! +//! A swarm names a stored lighthouse configuration (`lighthouse:` in the +//! swarm file). A shared swarm names a shared one, so that everyone who uses +//! the swarm gets it. Writing skips the Crazyflies that already have it, +//! which makes `swarm lh write` the way to give new Crazyflies the swarm's +//! configuration, and to update all of them when it changes. + +use anyhow::{anyhow, bail, Result}; +use tabled::Tabled; + +use super::runner::{csv_row_for, split, Runner, SwarmRow}; +use super::store::Swarm; +use super::Swarms; +use crate::error::CliError; +use crate::modules::documents::{Entry, SharedId}; +use crate::modules::lighthouse::configs::{describe, may_name, pick_entry, LhConfigs}; +use crate::modules::lighthouse::{ + check_supported, compare, describe_distance, is_same, read_config, supported_base_stations, write_config, + BaseStationDiff, CalibrationDelta, LighthouseConfigFile, Part, +}; +use crate::utils::display::{csv_row, print_table, table}; +use crate::{Config, SwarmLhCommands}; + +/// `swarm config lh [CONFIG] [--clear]`. Without a configuration, the user +/// picks one, or, when not interactive, it shows the one the swarm names. +pub async fn link( + swarms: &Swarms, + config: &Config, + id: &str, + new: Option<&str>, + clear: bool, + non_interactive: bool, +) -> Result<()> { + let picked; + let new = match new { + Some(new) => Some(new), + None if clear => None, + None => { + let current = swarms.load(id).await?.lighthouse; + if non_interactive { + match current { + Some(lighthouse) => println!("Swarm '{}' flies in lighthouse config '{}'", id, lighthouse), + None => println!( + "Swarm '{}' names no lighthouse config; set one with 'cfcli swarm config lh '", + id + ), + } + return Ok(()); + } + picked = pick_for(config, id, current.as_deref()).await?; + Some(picked.as_str()) + } + }; + if let Some(new) = new { + if !may_name(id, new) { + bail!(CliError::InvalidValue(match (SharedId::parse(id)?, SharedId::parse(new)?) { + (None, _) => format!( + "'{}' is a swarm on this computer, so it can only name a lighthouse config on this \ + computer, not '{}'; share the swarm with 'cfcli swarm config move {} /{}' to fly in \ + a shared one", + id, new, id, id + ), + (Some(swarm), None) => format!( + "'{}' is shared in {}, so it can only name a lighthouse config in {} ({}/), not \ + '{}' on this computer; share it with 'cfcli lh config move {} {}/{}'", + id, swarm.org, swarm.org, swarm.org, new, new, swarm.org, new + ), + (Some(swarm), Some(_)) => format!( + "'{}' is shared in {}, so it can only name a lighthouse config in {} ({}/), not '{}'", + id, swarm.org, swarm.org, swarm.org, new + ), + })); + } + // It must exist (and a shared one gets a copy here). + LhConfigs::open(config)?.load(new).await?; + } + let new = new.map(str::to_string); + swarms + .change(id, |swarm| { + swarm.lighthouse = new.clone(); + Ok(()) + }) + .await?; + match &new { + Some(new) => println!("Swarm '{}' flies in lighthouse config '{}'", id, new), + None => println!("Swarm '{}' names no lighthouse config now", id), + } + Ok(()) +} + +/// Let the user pick the configuration swarm `id` flies in: one on this +/// computer for a local swarm, one in its organization for a shared one. +async fn pick_for(config: &Config, id: &str, current: Option<&str>) -> Result { + let org = SharedId::parse(id)?.map(|shared| shared.org); + let entries: Vec = LhConfigs::open(config)? + .entries() + .await? + .into_iter() + .filter(|entry| may_name(id, &entry.id)) + .collect(); + if entries.is_empty() { + bail!(CliError::NotFound(match &org { + None => "lighthouse configs on this computer; store one with 'cfcli lh config save ' or \ + 'cfcli lh config import '" + .to_string(), + Some(org) => format!( + "lighthouse configs in {}; store one with 'cfcli lh config save {}/', or share one with \ + 'cfcli lh config move {}/'", + org, org, org + ), + })); + } + let message = match current { + Some(current) => format!("Lighthouse config for swarm '{}' (now '{}'):", id, current), + None => format!("Lighthouse config for swarm '{}':", id), + }; + pick_entry(&entries, &message, current) +} + +/// `swarm lh check|write` +pub async fn run( + config: &Config, + runner: &Runner<'_>, + swarm_id: &str, + linked: Option<&str>, + command: &SwarmLhCommands, + csv: bool, +) -> Result<()> { + let given = match command { + SwarmLhCommands::Check(params) => params.config.as_deref(), + SwarmLhCommands::Write(params) => params.config.as_deref(), + }; + let Some(id) = given.or(linked) else { + bail!(CliError::NotFound(format!( + "lighthouse config of swarm '{}'; name one with 'cfcli swarm config lh ' or give --config", + swarm_id + ))); + }; + let configs = LhConfigs::open(config)?; + let wanted = configs.load(id).await?; + if !csv { + println!( + "Lighthouse config {}: base stations {}", + describe(&configs, id), + ids(&wanted.geos.keys().copied().collect::>()) + ); + } + match command { + SwarmLhCommands::Check(_) => check(runner, &wanted, csv).await, + SwarmLhCommands::Write(params) => write(runner, &wanted, params.force).await, + } +} + +fn ids(ids: &[u8]) -> String { + match ids { + [] => "none".to_string(), + ids => ids.iter().map(|id| id.to_string()).collect::>().join(", "), + } +} + +/// What a check found on one Crazyflie. +struct Found { + diffs: Vec, + /// How many base stations its firmware supports, if that can be told. + supported: Option, +} + +impl Found { + fn up_to_date(&self) -> bool { + is_same(&self.diffs) + } + + /// Base stations whose geometry or calibration differs. + fn differing(&self) -> Vec { + self.diffs.iter().filter(|d| !d.is_same()).map(|d| d.id).collect() + } + + /// "BS 4, 5 not on the Crazyflie; BS 2 moved 3.1 cm; firmware supports 4 base stations" + fn summary(&self, wanted: &LighthouseConfigFile) -> String { + if self.up_to_date() { + return "up to date".to_string(); + } + let select = |f: &dyn Fn(&BaseStationDiff) -> bool| { + self.diffs.iter().filter(|d| f(d)).map(|d| d.id).collect::>() + }; + let mut parts = Vec::new(); + let missing = select(&|d| matches!(d.geometry, Part::OnlyInFile)); + if !missing.is_empty() { + parts.push(format!("no position for BS {}", ids(&missing))); + } + for d in &self.diffs { + if let Part::Differs(delta) = d.geometry { + parts.push(format!( + "BS {} moved {}, turned {:.2}°", + d.id, + describe_distance(delta.moved_m), + delta.turned_deg + )); + } + } + let extra = select(&|d| matches!(d.geometry, Part::OnlyOnCf)); + if !extra.is_empty() { + parts.push(format!("position for BS {} that the config hasn't", ids(&extra))); + } + let replaced = select(&|d| matches!(d.calibration, Part::Differs(CalibrationDelta::Replaced { .. }))); + if !replaced.is_empty() { + parts.push(format!("another base station on BS {}", ids(&replaced))); + } + let calibration = select(&|d| { + matches!( + d.calibration, + Part::Differs(CalibrationDelta::Values { .. }) | Part::OnlyInFile | Part::OnlyOnCf + ) + }); + if !calibration.is_empty() { + parts.push(format!("calibration differs for BS {}", ids(&calibration))); + } + if let Some(supported) = self.supported { + if wanted.ids().iter().any(|id| *id >= supported) { + parts.push(format!("its firmware supports only BS 0-{}", supported.saturating_sub(1))); + } + } + parts.join("; ") + } +} + +/// One row of `swarm lh check`. +#[derive(Tabled)] +struct CheckRow { + #[tabled(rename = "Lighthouse")] + status: String, +} + +async fn check(runner: &Runner<'_>, wanted: &LighthouseConfigFile, csv: bool) -> Result<()> { + let results = runner + .connected("Reading", async |cf, _| { + let on_cf = read_config(cf, |_, _| {}).await?; + Ok(Found { diffs: compare(wanted, &on_cf), supported: supported_base_stations(cf) }) + }) + .await; + let (done, outcome) = split(results); + let differing = done.iter().filter(|(_, found)| !found.up_to_date()).count(); + if csv { + csv_row(&["cf", "uri", "status", "firmware_base_stations", "differing_base_stations"]); + for (i, found) in &done { + let differing: Vec = found.differing().iter().map(|id| id.to_string()).collect(); + csv_row_for( + &runner.targets[*i], + &[ + if found.up_to_date() { "up_to_date" } else { "differs" }, + &found.supported.map(|n| n.to_string()).unwrap_or_default(), + &differing.join(";"), + ], + ); + } + } else { + let rows: Vec> = done + .iter() + .map(|(i, found)| SwarmRow { + cf: runner.targets[*i].name.clone(), + row: CheckRow { status: found.summary(wanted) }, + }) + .collect(); + if !rows.is_empty() { + print_table(&table(&rows)); + } + } + outcome.print_failures(&runner.targets); + outcome.finish()?; + if differing > 0 { + bail!(CliError::Differs(format!( + "{} of {} {} another lighthouse configuration; 'cfcli swarm lh write' gives them this one", + differing, + super::crazyflies(done.len()), + if differing == 1 { "has" } else { "have" } + ))); + } + Ok(()) +} + +async fn write(runner: &Runner<'_>, wanted: &LighthouseConfigFile, force: bool) -> Result<()> { + let results = runner + .connected("Writing", async |cf, _| { + // Refused before anything is written. + check_supported(wanted, supported_base_stations(cf))?; + if !force && is_same(&compare(wanted, &read_config(cf, |_, _| {}).await?)) { + return Ok(false); + } + write_config(cf, wanted, |_, _| {}).await?; + let diffs = compare(wanted, &read_config(cf, |_, _| {}).await?); + if !is_same(&diffs) { + return Err(not_kept(wanted, diffs)); + } + Ok(true) + }) + .await; + let (done, outcome) = split(results); + for (i, target) in runner.targets.iter().enumerate() { + if let Some((_, written)) = done.iter().find(|(d, _)| *d == i) { + println!( + "{}: {}", + target.name, + if *written { "written and stored in flash" } else { "up to date" } + ); + } + } + outcome.print_failures(&runner.targets); + outcome.finish() +} + +/// Why a Crazyflie doesn't have the configuration when it is read back +/// right after writing. Usually the Crazyflie has taken the calibration of +/// a base station it sees whose UID isn't the configuration's (see +/// [`CalibrationDelta::Replaced`]). +fn not_kept(wanted: &LighthouseConfigFile, diffs: Vec) -> anyhow::Error { + let replaced: Vec = diffs + .iter() + .filter_map(|d| match d.calibration { + Part::Differs(CalibrationDelta::Replaced { file_uid, cf_uid }) => Some(format!( + "BS {} sees 0x{:08X}, the config has 0x{:08X}", + d.id, cf_uid, file_uid + )), + _ => None, + }) + .collect(); + let only_replaced = diffs.iter().all(|d| { + matches!(d.geometry, Part::Same | Part::Absent) + && matches!( + d.calibration, + Part::Same | Part::Absent | Part::Differs(CalibrationDelta::Replaced { .. }) + ) + }); + if !replaced.is_empty() && only_replaced { + return anyhow!( + "written, but then the Crazyflie took the calibration of the base stations it sees, which aren't the \ + config's ({}). If a base station was replaced, its geometry may need a new estimate; then store \ + the configuration again with 'cfcli lh config save'", + replaced.join("; ") + ); + } + let found = Found { diffs, supported: None }; + anyhow!("the Crazyflie has another configuration after writing it: {}", found.summary(wanted)) +} + +/// A hint after Crazyflies were added to a swarm that names a lighthouse +/// configuration: how to give it to them. +pub fn hint_for_new(swarm: &str, lighthouse: Option<&str>, added: &[String]) { + if let (Some(lighthouse), false) = (lighthouse, added.is_empty()) { + println!( + "The swarm flies in lighthouse config '{}'; give it to the new Crazyflies with \ + 'cfcli swarm lh write --swarm {} --cf {}'", + lighthouse, + swarm, + added.join(",") + ); + } +} + +/// When a swarm becomes swarm `id` (moved or imported), drop the lighthouse +/// configuration it names if `id` can't name it (see [`may_name`]): the +/// server refuses a shared swarm naming another organization's. Returns what +/// to tell the user. +pub fn drop_unnameable(id: &str, swarm: &mut Swarm) -> Option { + let dropped = swarm.lighthouse.take_if(|lighthouse| !may_name(id, lighthouse))?; + Some(format!( + "Swarm '{}' names no lighthouse config: it can't name '{}' (a local swarm names lighthouse configs on \ + this computer, a shared swarm those in its organization); name one with 'cfcli swarm config lh \ + --swarm {}'", + id, dropped, id + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn drops_what_the_new_id_cannot_name() { + let swarm = |lighthouse: &str| Swarm { + lighthouse: Some(lighthouse.to_string()), + ..Swarm::new("s".to_string(), None) + }; + for (id, lighthouse, kept) in [ + ("lab/s", "lab/cage", true), + ("other/s", "lab/cage", false), + ("s", "lab/cage", false), + ("lab/s", "cage", false), + ("s", "cage", true), + ] { + let mut moved = swarm(lighthouse); + let note = drop_unnameable(id, &mut moved); + assert_eq!(moved.lighthouse.is_some(), kept, "{} naming {}", id, lighthouse); + assert_eq!(note.is_none(), kept, "{} naming {}", id, lighthouse); + } + let mut none = Swarm::new("s".to_string(), None); + assert!(drop_unnameable("lab/s", &mut none).is_none()); + } + + const FILE: &str = "\ +type: lighthouse_system_configuration +version: '1' +systemType: 2 +geos: + 0: + origin: [0.0, 0.0, 2.0] + rotation: [[1, 0, 0], [0, 1, 0], [0, 0, 1]] +calibs: + 0: + uid: 2360210604 + sweeps: + - {phase: 0.0, tilt: -0.051, curve: 0.275, gibmag: -0.005, gibphase: 2.281, ogeemag: -0.184, ogeephase: 1.847} + - {phase: -0.004, tilt: 0.047, curve: 0.367, gibmag: -0.005, gibphase: 2.548, ogeemag: -0.124, ogeephase: 2.051} +"; + + #[test] + fn says_why_a_write_did_not_stay() { + let wanted = LighthouseConfigFile::from_yaml(FILE).unwrap(); + + // The Crazyflie took the calibration of the base station it sees. + let mut seen = wanted.clone(); + seen.calibs.get_mut(&0).unwrap().uid = 0x12345678; + let message = not_kept(&wanted, compare(&wanted, &seen)).to_string(); + assert!(message.contains("BS 0 sees 0x12345678, the config has 0x8CADF4AC"), "{}", message); + + // Anything else: what differs. + let mut moved = seen.clone(); + moved.geos.get_mut(&0).unwrap().origin[0] = 0.05; + let message = not_kept(&wanted, compare(&wanted, &moved)).to_string(); + assert!(message.starts_with("the Crazyflie has another configuration"), "{}", message); + assert!(message.contains("BS 0 moved 5.0 cm"), "{}", message); + } +} diff --git a/src/modules/swarm/mod.rs b/src/modules/swarm/mod.rs index 294ed59..5ae649c 100644 --- a/src/modules/swarm/mod.rs +++ b/src/modules/swarm/mod.rs @@ -3,7 +3,8 @@ //! //! Local swarms are files in the Swarmkeeper format (see [`store`]), named //! ``. When cfcli is signed in (`cfcli auth login`), shared swarms on -//! the server sit next to them, named `/` (see [`shared`]); every +//! the server sit next to them, named `/` (see +//! [`crate::modules::documents`]); every //! command takes either. One swarm is selected and stored in the cfcli //! config; `--swarm` picks another one for a single command. Crazyflies are //! named by their short `name`, which is also what `--cf`/`--exclude` and @@ -11,9 +12,9 @@ mod bootload; mod commands; +mod lighthouse; mod rechannel; mod runner; -pub mod shared; pub mod store; use anyhow::{anyhow, bail, Context, Result}; @@ -30,121 +31,12 @@ use crate::{ SwarmExportParameters, SwarmImportParameters, SwarmTargetArgs, }; use runner::Runner; -use shared::{Shared, SharedId}; -use store::{Store, Swarm, Unit}; +use crate::modules::documents::{Documents, Entry, SharedId}; +use store::{Swarm, Unit}; /// Local and shared swarms behind one kind of ID: `` is a file in /// the swarms folder, `/` a swarm on the server. -pub(crate) struct Swarms { - pub local: Store, - /// None when cfcli isn't signed in. - pub shared: Option, -} - -impl Swarms { - pub fn open(config: &Config) -> Result { - Ok(Swarms { local: Store::open()?, shared: Shared::open(config.sync_on())? }) - } - - /// For shell completion: only [`Swarms::cached`] and - /// [`Swarms::cached_ids`] are used, which never ask the server. - pub fn open_cached() -> Result { - Ok(Swarms { local: Store::open()?, shared: Shared::open(false)? }) - } - - fn shared(&self, id: &SharedId) -> Result<&Shared> { - self.shared.as_ref().ok_or_else(|| { - CliError::NotFound(format!( - "a sign-in: '{}' is a shared swarm, sign in with 'cfcli auth login'", - id - )) - .into() - }) - } - - pub async fn load(&self, id: &str) -> Result { - match SharedId::parse(id)? { - None => self.local.load(id), - Some(shared) => self.shared(&shared)?.load(&shared).await, - } - } - - /// The swarm without asking the server (a shared one's copy), for shell - /// completion and pickers. - pub fn cached(&self, id: &str) -> Result { - match SharedId::parse(id)? { - None => self.local.load(id), - Some(shared) => self.shared(&shared)?.cached(&shared), - } - } - - /// Change a swarm. `change` may run more than once for a shared swarm - /// (see [`Shared::change`]), so it must not ask the user anything. - pub async fn change(&self, id: &str, mut change: impl FnMut(&mut Swarm) -> Result) -> Result { - match SharedId::parse(id)? { - None => { - let mut swarm = self.local.load(id)?; - let before = swarm.to_yaml()?; - let result = change(&mut swarm)?; - if swarm.to_yaml()? != before { - self.local.save(id, &swarm)?; - } - Ok(result) - } - Some(shared) => self.shared(&shared)?.change(&shared, change).await, - } - } - - /// Create a swarm. A shared one is created on the server. - pub async fn create(&self, id: &str, swarm: &Swarm) -> Result<()> { - match SharedId::parse(id)? { - None => { - store::check_id(id)?; - if self.local.exists(id) { - bail!(CliError::InvalidValue(format!("swarm '{}' already exists", id))); - } - self.local.save(id, swarm) - } - Some(shared_id) => { - let shared = self.shared(&shared_id)?; - if !shared.create(&shared_id, swarm).await? { - bail!(CliError::InvalidValue(format!( - "swarm '{}' already exists on {}", - id, - shared.host() - ))); - } - Ok(()) - } - } - } - - /// Delete a swarm. A shared one is deleted on the server, for everyone. - pub async fn delete(&self, id: &str) -> Result<()> { - match SharedId::parse(id)? { - None => self.local.delete(id), - Some(shared) => self.shared(&shared)?.delete(&shared).await, - } - } - - /// Local swarms and the shared ones there are copies of, without asking - /// the server. - pub fn cached_ids(&self) -> Result> { - let mut ids = self.local.ids()?; - if let Some(shared) = &self.shared { - ids.extend(shared.cached_ids().into_iter().map(|id| id.to_string())); - } - Ok(ids) - } - - /// Where a swarm is kept, for the user. - fn place(&self, id: &str) -> String { - match (&self.shared, SharedId::parse(id)) { - (Some(shared), Ok(Some(_))) => shared.host().to_string(), - _ => "this computer".to_string(), - } - } -} +pub(crate) type Swarms = Documents; pub(crate) async fn run( config: &mut Config, @@ -187,12 +79,16 @@ pub(crate) async fn run( export(&swarms, &swarm_id(config, params.id.as_deref())?, params).await } SwarmConfigCommands::Move { from, to } => move_swarm(&swarms, config, from, to, non_interactive).await, + SwarmConfigCommands::Lh { config: new, clear, swarm } => { + let id = swarm_id(config, swarm.as_deref())?; + lighthouse::link(&swarms, config, &id, new.as_deref(), *clear, non_interactive).await + } SwarmConfigCommands::Pull { id, force } => { - let (shared, only) = shared_for_sync(&swarms, id.as_deref())?; + let (shared, only) = swarms.for_sync(id.as_deref())?; shared.pull(only.as_ref(), *force).await } SwarmConfigCommands::Push { id, force } => { - let (shared, only) = shared_for_sync(&swarms, id.as_deref())?; + let (shared, only) = swarms.for_sync(id.as_deref())?; shared.push(only.as_ref(), *force).await } }, @@ -223,6 +119,13 @@ pub(crate) async fn run( let runner = runner(&swarms, config, target, link_context, toc_cache).await?; commands::debug(&runner, command, csv).await } + SwarmCommands::Lh { target, command } => { + let id = swarm_id(config, target.swarm.as_deref())?; + let swarm = swarms.load(&id).await?; + let selected = swarm.select(&id, &target.cf, &target.exclude)?; + let runner = Runner::new(link_context, config, toc_cache, &swarm, &selected).await?; + lighthouse::run(config, &runner, &id, swarm.lighthouse.as_deref(), command, csv).await + } SwarmCommands::Rechannel(params) => { rechannel::rechannel(&swarms, config, params, link_context, toc_cache, non_interactive).await } @@ -253,25 +156,6 @@ fn swarm_id(config: &Config, given: Option<&str>) -> Result { } } -/// The server for `pull`/`push`, and the one shared swarm to sync if given. -fn shared_for_sync<'a>(swarms: &'a Swarms, id: Option<&str>) -> Result<(&'a Shared, Option)> { - let Some(shared) = &swarms.shared else { - bail!(CliError::NotFound( - "a sign-in: shared swarms need 'cfcli auth login'".to_string() - )); - }; - let only = match id { - None => None, - Some(id) => Some(SharedId::parse(id)?.ok_or_else(|| { - CliError::InvalidValue(format!( - "'{}' is a local swarm; only shared swarms (/) are pulled and pushed", - id - )) - })?), - }; - Ok((shared, only)) -} - fn save_config(config: &Config) { confy::store("cf-cli", None, config.clone()).unwrap_or_else(|err| { println!("Could not save configuration: {:?}", err); @@ -304,57 +188,6 @@ fn crazyflies(n: usize) -> String { } } -/// A swarm as `list` and the pickers show it. -struct Entry { - id: String, - name: String, - count: String, - /// Where it is kept: "this computer" or the server. - place: String, - revision: Option, - pending: bool, -} - -impl Entry { - fn stored(&self) -> String { - match self.revision { - None => self.place.clone(), - Some(0) => format!("{}, not uploaded yet", self.place), - Some(revision) if self.pending => format!("{}, revision {}, changes not pushed", self.place, revision), - Some(revision) => format!("{}, revision {}", self.place, revision), - } - } -} - -/// The local swarms, then the shared ones. -async fn entries(swarms: &Swarms) -> Result> { - let mut entries: Vec = swarms - .local - .ids()? - .into_iter() - .map(|id| { - let (name, count) = match swarms.local.load(&id) { - Ok(swarm) => (swarm.name, swarm.units.len().to_string()), - Err(e) => (format!("(can't read: {:#})", e), "?".to_string()), - }; - Entry { id, name, count, place: "this computer".to_string(), revision: None, pending: false } - }) - .collect(); - if let Some(shared) = &swarms.shared { - for listed in shared.list().await? { - entries.push(Entry { - id: listed.id.to_string(), - name: listed.name, - count: listed.units.to_string(), - place: shared.host().to_string(), - revision: Some(listed.copy.revision), - pending: listed.copy.pending, - }); - } - } - Ok(entries) -} - /// One row of `swarm config list`. #[derive(Tabled)] struct SwarmRow { @@ -371,7 +204,7 @@ struct SwarmRow { } async fn list(swarms: &Swarms, config: &Config, csv: bool) -> Result<()> { - let entries = entries(swarms).await?; + let entries = swarms.entries().await?; let selected = |entry: &Entry| config.swarm.as_deref() == Some(entry.id.as_str()); if csv { @@ -427,7 +260,7 @@ async fn select(swarms: &Swarms, config: &mut Config, id: Option<&str>, non_inte /// Let the user pick a swarm, starting at the selected one. async fn pick_swarm(swarms: &Swarms, config: &Config, message: &str) -> Result { - let entries = entries(swarms).await?; + let entries = swarms.entries().await?; if entries.is_empty() { bail!(CliError::NotFound( "swarms; create one with 'cfcli swarm config create '".to_string() @@ -457,7 +290,7 @@ async fn create(swarms: &Swarms, config: &mut Config, params: &SwarmCreateParame // of the name. let name = match (¶ms.name, SharedId::parse(¶ms.id)?) { (Some(name), _) => name.clone(), - (None, Some(shared)) => shared.swarm, + (None, Some(shared)) => shared.name, (None, None) => params.id.clone(), }; let swarm = Swarm::new(name, params.description.clone()); @@ -558,6 +391,9 @@ async fn show(swarms: &Swarms, id: &str, csv: bool) -> Result<()> { if SharedId::parse(id)?.is_some() { println!("Shared on {}", swarms.place(id)); } + if let Some(lighthouse) = &swarm.lighthouse { + println!("Flies in lighthouse config '{}'", lighthouse); + } println!(); if swarm.units.is_empty() { println!("No Crazyflies yet, add some with 'cfcli swarm config add'."); @@ -663,9 +499,10 @@ async fn add_to( } } - let messages = swarms + let (messages, added, lighthouse) = swarms .change(id, |swarm| { let mut messages = Vec::new(); + let mut added = Vec::new(); for uri in &uris { if let Some(i) = swarm.find_link(uri)? { messages.push(format!("{} is already in the swarm as {}", uri, swarm.units[i].name)); @@ -680,6 +517,7 @@ async fn add_to( None => swarm.next_name(), }; messages.push(format!("Added {} {}", name, uri)); + added.push(name.clone()); swarm.units.push(Unit { uri: uri.clone(), name, @@ -687,12 +525,13 @@ async fn add_to( extra: Default::default(), }); } - Ok(messages) + Ok((messages, added, swarm.lighthouse.clone())) }) .await?; for message in messages { println!("{}", message); } + lighthouse::hint_for_new(id, lighthouse.as_deref(), &added); Ok(()) } @@ -856,7 +695,7 @@ async fn import(swarms: &Swarms, config: &mut Config, params: &SwarmImportParame } // Read and check every file before writing any of them. - let mut imports: Vec<(String, Swarm, &str)> = Vec::new(); + let mut imports: Vec<(String, Swarm, &str, Option)> = Vec::new(); for file in ¶ms.files { let yaml = match std::fs::read_to_string(file) { Ok(yaml) => yaml, @@ -865,7 +704,7 @@ async fn import(swarms: &Swarms, config: &mut Config, params: &SwarmImportParame } Err(e) => return Err(e).with_context(|| format!("reading {}", file)), }; - let swarm = Swarm::from_yaml(&yaml).with_context(|| format!("in {}", file))?; + let mut swarm = Swarm::from_yaml(&yaml).with_context(|| format!("in {}", file))?; let id = match ¶ms.id { Some(id) => id.clone(), None => std::path::Path::new(file) @@ -887,13 +726,14 @@ async fn import(swarms: &Swarms, config: &mut Config, params: &SwarmImportParame swarms.shared(&shared)?; } } - if imports.iter().any(|(other, _, _)| *other == id) { + if imports.iter().any(|(other, _, _, _)| *other == id) { bail!(CliError::InvalidValue(format!("two of the files would both become swarm '{}'", id))); } - imports.push((id, swarm, file)); + let dropped = lighthouse::drop_unnameable(&id, &mut swarm); + imports.push((id, swarm, file, dropped)); } - for (id, swarm, file) in &mut imports { + for (id, swarm, file, dropped) in &mut imports { let any_radio = swarm.use_any_radio(); match SharedId::parse(id)? { None => swarms.local.save(id, swarm)?, @@ -921,8 +761,11 @@ async fn import(swarms: &Swarms, config: &mut Config, params: &SwarmImportParame print!(", {} URIs now use radio:/// (any Crazyradio)", any_radio); } println!(); + if let Some(note) = dropped { + println!("{}", note); + } } - if let Some((id, swarm, _)) = imports.first() { + if let Some((id, swarm, _, _)) = imports.first() { select_if_none(swarms, config, id, swarm); } Ok(()) @@ -952,7 +795,8 @@ async fn move_swarm(swarms: &Swarms, config: &mut Config, from: &str, to: &str, } let from_shared = SharedId::parse(from)?; let to_shared = SharedId::parse(to)?; - let swarm = swarms.load(from).await?; + let mut swarm = swarms.load(from).await?; + let dropped = lighthouse::drop_unnameable(to, &mut swarm); if to_shared.is_none() && swarms.local.exists(to) { bail!(CliError::InvalidValue(format!("swarm '{}' already exists", to))); } @@ -985,6 +829,9 @@ async fn move_swarm(swarms: &Swarms, config: &mut Config, from: &str, to: &str, to, swarms.place(to) ); + if let Some(note) = dropped { + println!("{}", note); + } if config.swarm.as_deref() == Some(from) { set_selected(config, to, &swarm); } diff --git a/src/modules/swarm/shared.rs b/src/modules/swarm/shared.rs deleted file mode 100644 index 2f399b9..0000000 --- a/src/modules/swarm/shared.rs +++ /dev/null @@ -1,837 +0,0 @@ -//! Shared swarms: swarms kept on the server (`cfcli auth login`), named -//! `/`, next to the local ones, which are named ``. -//! -//! cfcli keeps a copy of each shared swarm it uses in -//! `synced///.yaml` next to the cfcli config. That is -//! outside the `swarms` folder, so Swarmkeeper and `import` never take a copy -//! for a local swarm. `synced//state.json` says which revision each -//! copy is and whether it has changes the server doesn't have yet. -//! -//! With sync on (`cfcli settings sync`, the default) a command checks the -//! server before it uses a shared swarm, which costs a 304 when the copy is -//! current, and writes changes straight to the server. The upload carries -//! `If-Match` with the copy's revision, so nobody's upload is overwritten: -//! when someone else changed the swarm in between, the change is applied -//! again to their version. With sync off, commands use the copies only, and -//! `swarm config pull` and `push` sync them. -//! -//! When the server can't be reached, commands use the copies and keep their -//! changes for the next push, so working with the Crazyflies never waits for -//! the internet. -//! -//! The `` in an ID is the user's own ID for the organization, which they -//! can change on the server. `synced//orgs.json` remembers the ID of -//! each organization (the server lists their UUIDs, which never change), so -//! when one changes, the copies and the selected swarm move to the new ID, -//! and the old ID says what the new one is. - -use std::collections::BTreeMap; -use std::fmt; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; - -use anyhow::{bail, Context, Result}; -use reqwest::{header, RequestBuilder, Response, StatusCode}; -use serde::{Deserialize, Serialize}; - -use super::store::{self, Store, Swarm}; -use crate::error::CliError; -use crate::modules::auth::{self, Credentials}; -use crate::Config; - -/// A shared swarm's ID, `/`. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct SharedId { - pub org: String, - pub swarm: String, -} - -impl SharedId { - /// `/`, or None for a local ID (no '/'). - pub fn parse(id: &str) -> Result> { - let Some((org, swarm)) = id.split_once('/') else { - return Ok(None); - }; - let org_ok = !org.is_empty() - && org.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); - if !org_ok { - bail!(CliError::InvalidValue(format!( - "swarm ID '{}': the organization is lower-case letters, digits and '-'", - id - ))); - } - store::check_id(swarm)?; - Ok(Some(SharedId { org: org.to_string(), swarm: swarm.to_string() })) - } -} - -impl fmt::Display for SharedId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}/{}", self.org, self.swarm) - } -} - -/// What cfcli knows about its copy of a shared swarm. -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] -pub struct CopyState { - /// The server's revision the copy is based on. 0 for a swarm created - /// here while the server couldn't be reached, which isn't on it yet. - pub revision: i32, - /// The copy has changes the server doesn't have. - pub pending: bool, -} - -#[derive(Debug, Default, Serialize, Deserialize)] -struct State { - #[serde(default)] - swarms: BTreeMap, -} - -/// A shared swarm as `swarm config list` shows it. -pub struct Listed { - pub id: SharedId, - pub name: String, - pub units: usize, - pub copy: CopyState, -} - -#[derive(Deserialize)] -struct ServerSwarm { - org: String, - /// Never changes, unlike the user's ID for the organization (`org`). - /// Servers before per-user IDs don't send it. - #[serde(default)] - org_uuid: Option, - swarm: String, - name: String, - units: usize, - revision: i32, -} - -/// `orgs.json`: the user's IDs for their organizations. -#[derive(Debug, Default, Serialize, Deserialize)] -struct OrgIds { - /// The ID of each organization, by UUID, as the server last listed it. - #[serde(default)] - ids: BTreeMap, - /// Old IDs and what they became, for telling the user. - #[serde(default)] - renamed: BTreeMap, -} - -/// What an upload asks the server to check first. -#[derive(Clone, Copy)] -enum Condition { - /// Only if the swarm is still at this revision. - Revision(i32), - /// Only if the swarm doesn't exist. - New, - /// Overwrite whatever is there. - None, -} - -enum Upload { - Done(i32), - /// Someone else changed (or created, or deleted) the swarm first. - Conflict, - Offline, -} - -/// The shared swarms of the server cfcli is signed in to. -pub struct Shared { - credentials: Credentials, - /// `synced/` next to the cfcli config. - dir: PathBuf, - sync: bool, - client: reqwest::Client, - /// The server didn't answer once; don't wait for it again in this command. - offline: AtomicBool, -} - -impl Shared { - /// None when cfcli isn't signed in. Doesn't contact the server. - pub fn open(sync: bool) -> Result> { - let Some(credentials) = auth::load()? else { - return Ok(None); - }; - let config = confy::get_configuration_file_path("cf-cli", None) - .context("could not find the cfcli config folder")?; - // "localhost:3000" would be an invalid folder name on Windows. - let host = auth::host(&credentials.server).replace([':', '/', '\\'], "_"); - let dir = config.with_file_name("synced").join(host); - let client = reqwest::Client::builder() - .user_agent(concat!("cfcli/", env!("CARGO_PKG_VERSION"))) - .connect_timeout(Duration::from_secs(3)) - .timeout(Duration::from_secs(15)) - .build()?; - Ok(Some(Shared { credentials, dir, sync, client, offline: AtomicBool::new(false) })) - } - - pub fn host(&self) -> &str { - auth::host(&self.credentials.server) - } - - pub fn sync(&self) -> bool { - self.sync - } - - // ---- The copies and their state ---- - - fn copies(&self, org: &str) -> Store { - Store::at(self.dir.join(org)) - } - - fn state_path(&self) -> PathBuf { - self.dir.join("state.json") - } - - fn state(&self) -> Result { - let path = self.state_path(); - match std::fs::read_to_string(&path) { - Ok(text) => serde_json::from_str(&text).with_context(|| format!("reading {}", path.display())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(State::default()), - Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), - } - } - - fn save_state(&self, state: &State) -> Result<()> { - std::fs::create_dir_all(&self.dir).with_context(|| format!("creating {}", self.dir.display()))?; - let path = self.state_path(); - let tmp = self.dir.join(".state.json.tmp"); - std::fs::write(&tmp, serde_json::to_string_pretty(state)?).with_context(|| format!("writing {}", tmp.display()))?; - std::fs::rename(&tmp, &path).with_context(|| format!("writing {}", path.display())) - } - - pub fn copy_state(&self, id: &SharedId) -> Result> { - Ok(self.state()?.swarms.get(&id.to_string()).copied()) - } - - fn set_copy_state(&self, id: &SharedId, copy: Option) -> Result<()> { - let mut state = self.state()?; - match copy { - Some(copy) => state.swarms.insert(id.to_string(), copy), - None => state.swarms.remove(&id.to_string()), - }; - self.save_state(&state) - } - - /// Keep `swarm` as the copy of `id`. - fn keep(&self, id: &SharedId, swarm: &Swarm, copy: CopyState) -> Result<()> { - self.copies(&id.org).save(&id.swarm, swarm)?; - self.set_copy_state(id, Some(copy)) - } - - fn forget(&self, id: &SharedId) -> Result<()> { - let copies = self.copies(&id.org); - if copies.exists(&id.swarm) { - copies.delete(&id.swarm)?; - } - self.set_copy_state(id, None) - } - - fn has_copy(&self, id: &SharedId) -> bool { - self.copies(&id.org).exists(&id.swarm) - } - - /// The copy, without asking the server. For shell completion and the - /// like, which must never wait for the network. - pub fn cached(&self, id: &SharedId) -> Result { - if !self.has_copy(id) { - if let Some(now) = self.renamed(id)? { - bail!(renamed_error(id, &now)); - } - bail!(CliError::NotFound(format!("a copy of swarm '{}' on this computer", id))); - } - self.copies(&id.org).load(&id.swarm) - } - - /// The shared swarms there are copies of, without asking the server. - pub fn cached_ids(&self) -> Vec { - self.state() - .map(|state| state.swarms.keys().filter_map(|id| SharedId::parse(id).ok().flatten()).collect()) - .unwrap_or_default() - } - - // ---- Organization IDs ---- - - fn org_ids(&self) -> Result { - let path = self.dir.join("orgs.json"); - match std::fs::read_to_string(&path) { - Ok(text) => serde_json::from_str(&text).with_context(|| format!("reading {}", path.display())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(OrgIds::default()), - Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), - } - } - - fn save_org_ids(&self, orgs: &OrgIds) -> Result<()> { - std::fs::create_dir_all(&self.dir).with_context(|| format!("creating {}", self.dir.display()))?; - let path = self.dir.join("orgs.json"); - std::fs::write(&path, serde_json::to_string_pretty(orgs)?).with_context(|| format!("writing {}", path.display())) - } - - /// What `id` is called now, if the user changed the ID of its - /// organization. Also selects it instead, if `id` was selected. - fn renamed(&self, id: &SharedId) -> Result> { - let Some(org) = self.org_ids()?.renamed.get(&id.org).cloned() else { - return Ok(None); - }; - let now = SharedId { org, swarm: id.swarm.clone() }; - select_renamed(&[(id.org.clone(), now.org.clone())]); - Ok(Some(now)) - } - - /// Follow the organizations whose ID the user changed since the last - /// list: their copies move to the new ID, and so does the selected swarm. - fn follow_renames(&self, server: &[ServerSwarm]) -> Result<()> { - let mut orgs = self.org_ids()?; - let before = serde_json::to_string(&orgs)?; - let mut renames: Vec<(String, String)> = Vec::new(); - for s in server { - let Some(uuid) = &s.org_uuid else { continue }; - if let Some(old) = orgs.ids.insert(uuid.clone(), s.org.clone()) { - if old != s.org && !renames.contains(&(old.clone(), s.org.clone())) { - renames.push((old, s.org.clone())); - } - } - } - for (old, new) in &renames { - for to in orgs.renamed.values_mut().filter(|to| *to == old) { - *to = new.clone(); - } - orgs.renamed.insert(old.clone(), new.clone()); - } - // An ID in use is no longer an old one. - for s in server { - orgs.renamed.remove(&s.org); - } - if serde_json::to_string(&orgs)? != before { - self.save_org_ids(&orgs)?; - } - if renames.is_empty() { - return Ok(()); - } - - // Take all the moving copies out first, so that two organizations - // can swap IDs. - let mut state = self.state()?; - let mut moving = Vec::new(); - for (old, new) in &renames { - let ids: Vec = state - .swarms - .keys() - .filter_map(|key| SharedId::parse(key).ok().flatten()) - .filter(|id| id.org == *old) - .collect(); - for id in ids { - let copy = state.swarms.remove(&id.to_string()).unwrap_or_default(); - let copies = self.copies(old); - let swarm = match copies.exists(&id.swarm) { - true => { - let swarm = copies.load(&id.swarm)?; - copies.delete(&id.swarm)?; - Some(swarm) - } - false => None, - }; - moving.push((SharedId { org: new.clone(), swarm: id.swarm }, copy, swarm)); - } - } - for (id, copy, swarm) in moving { - if let Some(swarm) = swarm { - self.copies(&id.org).save(&id.swarm, &swarm)?; - } - state.swarms.insert(id.to_string(), copy); - } - self.save_state(&state)?; - for (old, _) in &renames { - // Only goes when empty. - let _ = std::fs::remove_dir(self.dir.join(old)); - } - select_renamed(&renames); - for (old, new) in &renames { - println!("Your organization '{}' on {} is now called '{}': its swarms are '{}/'", old, self.host(), new, new); - } - Ok(()) - } - - // ---- Talking to the server ---- - - fn url(&self, id: &SharedId) -> Result { - auth::endpoint(&self.credentials.server, &format!("/api/v1/swarms/{}/{}", id.org, id.swarm)) - } - - /// Send a request with the key. None when the server can't be reached; - /// the first time, cfcli says it uses the copies instead. - async fn send(&self, request: RequestBuilder) -> Result> { - self.send_quietly(request, false).await - } - - async fn send_quietly(&self, request: RequestBuilder, quiet: bool) -> Result> { - if self.offline.load(Ordering::Relaxed) { - return Ok(None); - } - match request.bearer_auth(&self.credentials.key).send().await { - Ok(response) if response.status() == StatusCode::UNAUTHORIZED => bail!(CliError::NotFound(format!( - "a working key: {} no longer accepts '{}'; sign in again with 'cfcli auth login'", - self.host(), - self.credentials.key_name - ))), - Ok(response) => Ok(Some(response)), - Err(e) if e.is_connect() || e.is_timeout() => { - self.offline.store(true, Ordering::Relaxed); - if !quiet { - eprintln!( - "Can't reach {}: using the copies of shared swarms on this computer. \ - Changes are kept and uploaded {}.", - self.host(), - if self.sync { - "when it answers again" - } else { - "with 'cfcli swarm config push'" - } - ); - } - Ok(None) - } - Err(e) => Err(e).with_context(|| CliError::Connection(self.host().to_string())), - } - } - - /// Like [`Shared::send`], for things that can't be done without the server. - async fn send_online(&self, request: RequestBuilder, what: &str) -> Result { - match self.send_quietly(request, true).await? { - Some(response) => Ok(response), - None => bail!(CliError::Connection(format!("{} needs {}, which can't be reached", what, self.host()))), - } - } - - /// The swarms on the server, in all the user's organizations. - async fn server_list(&self) -> Result>> { - let url = auth::endpoint(&self.credentials.server, "/api/v1/swarms")?; - let Some(response) = self.send(self.client.get(url)).await? else { - return Ok(None); - }; - let server: Vec = auth::answer(&self.credentials.server, response).await?; - self.follow_renames(&server)?; - Ok(Some(server)) - } - - /// After the server answered 404 for `id`: fail saying what it is called - /// now, if the user changed the ID of its organization. Gets the list, - /// which moves the copies. - async fn fail_if_renamed(&self, id: &SharedId) -> Result<()> { - self.server_list().await?; - match self.renamed(id)? { - Some(now) => bail!(renamed_error(id, &now)), - None => Ok(()), - } - } - - /// Download `id` into its copy unless the copy is current. Returns false - /// when the server can't be reached. - async fn download(&self, id: &SharedId, force: bool) -> Result { - let copy = self.copy_state(id)?.unwrap_or_default(); - let mut request = self.client.get(self.url(id)?); - if !force && copy.revision > 0 && self.has_copy(id) { - request = request.header(header::IF_NONE_MATCH, format!("\"{}\"", copy.revision)); - } - let Some(response) = self.send(request).await? else { - return Ok(false); - }; - match response.status() { - StatusCode::NOT_MODIFIED => Ok(true), - StatusCode::NOT_FOUND => { - self.fail_if_renamed(id).await?; - // Deleted on the server, or no longer visible to the user. - if copy.pending { - bail!(CliError::NotFound(format!( - "swarm '{}' on {}, though this computer has changes to it that aren't uploaded; \ - 'cfcli swarm config push --force {}' creates it again with them", - id, - self.host(), - id - ))); - } - self.forget(id)?; - bail!(CliError::NotFound(format!("swarm '{}' on {}", id, self.host()))) - } - status if status.is_success() => { - let revision = etag_revision(&response)?; - let yaml = response.text().await?; - let swarm = Swarm::from_yaml(&yaml).with_context(|| format!("swarm '{}' from {}", id, self.host()))?; - self.keep(id, &swarm, CopyState { revision, pending: false })?; - Ok(true) - } - _ => auth::answer::(&self.credentials.server, response).await.map(|_| true), - } - } - - async fn upload(&self, id: &SharedId, swarm: &Swarm, condition: Condition) -> Result { - let mut request = self - .client - .put(self.url(id)?) - .header(header::CONTENT_TYPE, "application/yaml") - .body(swarm.to_yaml()?); - request = match condition { - Condition::Revision(revision) => request.header(header::IF_MATCH, format!("\"{}\"", revision)), - Condition::New => request.header(header::IF_NONE_MATCH, "*"), - Condition::None => request, - }; - let Some(response) = self.send(request).await? else { - return Ok(Upload::Offline); - }; - if response.status() == StatusCode::PRECONDITION_FAILED { - return Ok(Upload::Conflict); - } - if response.status() == StatusCode::NOT_FOUND { - self.fail_if_renamed(id).await?; - } - #[derive(Deserialize)] - struct Uploaded { - revision: i32, - } - let uploaded: Uploaded = auth::answer(&self.credentials.server, response).await?; - Ok(Upload::Done(uploaded.revision)) - } - - // ---- What the swarm commands do ---- - - /// Upload the copy of `id` if it has changes. Fails on a conflict, saying - /// how to settle it. Returns false when the server can't be reached. - async fn push_pending(&self, id: &SharedId, force: bool) -> Result { - let Some(copy) = self.copy_state(id)? else { - return Ok(true); - }; - if !copy.pending { - return Ok(true); - } - let swarm = self.copies(&id.org).load(&id.swarm)?; - let condition = match (force, copy.revision) { - (true, _) => Condition::None, - (false, 0) => Condition::New, - (false, revision) => Condition::Revision(revision), - }; - match self.upload(id, &swarm, condition).await? { - Upload::Done(revision) => { - self.set_copy_state(id, Some(CopyState { revision, pending: false }))?; - Ok(true) - } - Upload::Offline => Ok(false), - Upload::Conflict => bail!(CliError::InvalidValue(format!( - "swarm '{}' changed on {} since this computer's copy ({}). Keep theirs with \ - 'cfcli swarm config pull --force {}', or yours with 'cfcli swarm config push --force {}'", - id, - self.host(), - match copy.revision { - 0 => "someone created it first".to_string(), - revision => format!("revision {}", revision), - }, - id, - id - ))), - } - } - - /// The swarm, checked with the server first when sync is on (or when - /// there's no copy yet). - pub async fn load(&self, id: &SharedId) -> Result { - if (self.sync || !self.has_copy(id)) && self.push_pending(id, false).await? { - self.download(id, false).await?; - } - self.cached(id) - } - - /// Change the swarm. `change` may run more than once: when someone else - /// uploaded in between, it runs again on their version. With sync off, - /// or without the server, it changes the copy and keeps it for a push. - pub async fn change(&self, id: &SharedId, mut change: impl FnMut(&mut Swarm) -> Result) -> Result { - let mut online = false; - if self.sync || !self.has_copy(id) { - online = self.push_pending(id, false).await? && self.download(id, false).await?; - } - if online { - for _ in 0..3 { - let mut swarm = self.cached(id)?; - let before = swarm.to_yaml()?; - let revision = self.copy_state(id)?.unwrap_or_default().revision; - let result = change(&mut swarm)?; - if swarm.to_yaml()? == before { - return Ok(result); - } - match self.upload(id, &swarm, Condition::Revision(revision)).await? { - Upload::Done(revision) => { - self.keep(id, &swarm, CopyState { revision, pending: false })?; - return Ok(result); - } - // Someone else was first: their version, then the change again. - Upload::Conflict => { - self.download(id, true).await?; - } - Upload::Offline => break, - } - } - } - let mut swarm = self.cached(id)?; - let before = swarm.to_yaml()?; - let result = change(&mut swarm)?; - if swarm.to_yaml()? == before { - return Ok(result); - } - let revision = self.copy_state(id)?.unwrap_or_default().revision; - self.keep(id, &swarm, CopyState { revision, pending: true })?; - if !self.sync { - println!("Changed this computer's copy of '{}'; 'cfcli swarm config push' uploads it", id); - } - Ok(result) - } - - /// Create the swarm on the server. Returns false when it already exists. - /// Without the server, the copy waits for a push. - pub async fn create(&self, id: &SharedId, swarm: &Swarm) -> Result { - if self.has_copy(id) && self.copy_state(id)?.is_some_and(|c| c.pending) { - return Ok(false); - } - match self.upload(id, swarm, Condition::New).await? { - Upload::Done(revision) => self.keep(id, swarm, CopyState { revision, pending: false })?, - Upload::Conflict => return Ok(false), - Upload::Offline => { - self.keep(id, swarm, CopyState { revision: 0, pending: true })?; - println!("Created '{}' on this computer; 'cfcli swarm config push' uploads it", id); - } - } - Ok(true) - } - - /// Delete the swarm on the server, and the copy. Needs the server, - /// unless the swarm never got there. - pub async fn delete(&self, id: &SharedId) -> Result<()> { - let copy = self.copy_state(id)?; - if copy.is_some_and(|c| c.revision == 0) { - return self.forget(id); - } - let mut request = self.client.delete(self.url(id)?); - // With sync off, the user decided on the copy they have: don't delete - // what someone uploaded since. With sync on, the copy is just the - // last one used, so the swarm goes whatever its revision. - if let Some(copy) = copy.filter(|_| !self.sync) { - request = request.header(header::IF_MATCH, format!("\"{}\"", copy.revision)); - } - let response = self.send_online(request, "deleting a shared swarm").await?; - match response.status() { - status if status.is_success() => self.forget(id), - StatusCode::NOT_FOUND => { - self.fail_if_renamed(id).await?; - self.forget(id)?; - bail!(CliError::NotFound(format!("swarm '{}' on {}", id, self.host()))) - } - StatusCode::PRECONDITION_FAILED => bail!(CliError::InvalidValue(format!( - "swarm '{}' changed on {} since this computer's copy; \ - 'cfcli swarm config pull --force {}' gets the new version", - id, - self.host(), - id - ))), - _ => auth::answer::(&self.credentials.server, response).await.map(|_| ()), - } - } - - /// The shared swarms: from the server when sync is on (and it answers), - /// else from the copies. Swarms created here and not uploaded are - /// included either way. - pub async fn list(&self) -> Result> { - let server = if self.sync { self.server_list().await? } else { None }; - // After the list, which moves the copies of renamed organizations. - let state = self.state()?; - let mut listed = Vec::new(); - match server { - Some(server) => { - for s in server { - let id = SharedId { org: s.org, swarm: s.swarm }; - let copy = state.swarms.get(&id.to_string()).copied().unwrap_or(CopyState { - revision: s.revision, - pending: false, - }); - listed.push(Listed { id, name: s.name, units: s.units, copy }); - } - // Copies of swarms gone from the server go too, unless they - // have changes to upload. - for (key, copy) in &state.swarms { - let Some(id) = SharedId::parse(key)? else { continue }; - if listed.iter().any(|l| l.id == id) { - continue; - } - if copy.pending { - listed.push(self.listed_copy(id, *copy)); - } else { - self.forget(&id)?; - } - } - } - None => { - for (key, copy) in &state.swarms { - if let Some(id) = SharedId::parse(key)? { - listed.push(self.listed_copy(id, *copy)); - } - } - } - } - listed.sort_by(|a, b| a.id.cmp(&b.id)); - Ok(listed) - } - - fn listed_copy(&self, id: SharedId, copy: CopyState) -> Listed { - let (name, units) = match self.cached(&id) { - Ok(swarm) => (swarm.name, swarm.units.len()), - Err(e) => (format!("(can't read: {:#})", e), 0), - }; - Listed { id, name, units, copy } - } - - /// Get the latest version of every shared swarm (or of one). Copies - /// with changes that aren't uploaded are kept, unless `force`. - pub async fn pull(&self, only: Option<&SharedId>, force: bool) -> Result<()> { - let Some(server) = self.server_list().await? else { - bail!(CliError::Connection(format!("{} can't be reached", self.host()))); - }; - let on_server: Vec = server.into_iter().map(|s| SharedId { org: s.org, swarm: s.swarm }).collect(); - if let Some(only) = only { - if let Some(now) = self.renamed(only)? { - bail!(renamed_error(only, &now)); - } - if !on_server.contains(only) { - bail!(CliError::NotFound(format!("swarm '{}' on {}", only, self.host()))); - } - } - let mut kept = 0; - for id in on_server.iter().filter(|id| only.is_none_or(|only| *id == only)) { - let copy = self.copy_state(id)?; - if copy.is_some_and(|c| c.pending) && !force { - println!("Kept '{}': it has changes that aren't uploaded", id); - kept += 1; - continue; - } - let before = copy.map(|c| c.revision); - self.download(id, force).await?; - let after = self.copy_state(id)?.map(|c| c.revision); - if before != after { - println!("Pulled '{}' (revision {})", id, after.unwrap_or_default()); - } - } - // Copies of swarms deleted on the server. - for id in self.cached_ids() { - if on_server.contains(&id) || only.is_some_and(|only| *only != id) { - continue; - } - let pending = self.copy_state(&id)?.is_some_and(|c| c.pending); - if pending && !force { - println!("Kept '{}': it is gone from {}, but has changes that aren't uploaded", id, self.host()); - kept += 1; - } else { - self.forget(&id)?; - println!("Removed '{}': it is gone from {}", id, self.host()); - } - } - if kept > 0 { - println!("Upload kept changes with 'cfcli swarm config push', or drop them with 'pull --force'"); - } - Ok(()) - } - - /// Upload the changes made while sync was off or the server was out of - /// reach. `force` overwrites what others uploaded in between. - pub async fn push(&self, only: Option<&SharedId>, force: bool) -> Result<()> { - // Copies of organizations with a new ID move to it first. - self.server_list().await?; - if let Some(only) = only { - if let Some(now) = self.renamed(only)? { - bail!(renamed_error(only, &now)); - } - } - let pending: Vec = self - .state()? - .swarms - .iter() - .filter(|(_, copy)| copy.pending) - .filter_map(|(id, _)| SharedId::parse(id).ok().flatten()) - .filter(|id| only.is_none_or(|only| id == only)) - .collect(); - if pending.is_empty() { - println!("Nothing to push"); - return Ok(()); - } - let mut failed = Vec::new(); - for id in &pending { - match self.push_pending(id, force).await { - Ok(true) => println!( - "Pushed '{}' (revision {})", - id, - self.copy_state(id)?.unwrap_or_default().revision - ), - Ok(false) => bail!(CliError::Connection(format!("{} can't be reached", self.host()))), - Err(e) => { - match e.downcast_ref::() { - Some(CliError::InvalidValue(message)) => println!("{}", message), - _ => println!("{:#}", e), - } - failed.push(id.to_string()); - } - } - } - if !failed.is_empty() { - bail!(CliError::InvalidValue(format!("not pushed: {}", failed.join(", ")))); - } - Ok(()) - } -} - -fn renamed_error(id: &SharedId, now: &SharedId) -> CliError { - CliError::NotFound(format!( - "swarm '{}' (your organization '{}' is now called '{}'; use '{}')", - id, id.org, now.org, now - )) -} - -/// Select the swarm under its organization's new ID, if one of `renames` -/// (old, new) changed the selected swarm's. -fn select_renamed(renames: &[(String, String)]) { - let Ok(mut config) = confy::load::("cf-cli", None) else { - return; - }; - let Some(selected) = config.swarm.as_deref().and_then(|id| SharedId::parse(id).ok().flatten()) else { - return; - }; - if let Some((_, new)) = renames.iter().find(|(old, _)| *old == selected.org) { - let now = SharedId { org: new.clone(), swarm: selected.swarm }; - config.swarm = Some(now.to_string()); - if confy::store("cf-cli", None, config).is_ok() { - println!("The selected swarm is '{}' now", now); - } - } -} - -/// The revision in a response's ETag, `"N"`. -fn etag_revision(response: &Response) -> Result { - response - .headers() - .get(header::ETAG) - .and_then(|etag| etag.to_str().ok()) - .and_then(|etag| etag.trim_start_matches("W/").trim_matches('"').parse().ok()) - .context("the server sent a swarm without its revision") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_shared_and_local_ids() { - assert_eq!(SharedId::parse("lab").unwrap(), None); - let id = SharedId::parse("bitcraze-lab/cage").unwrap().unwrap(); - assert_eq!((id.org.as_str(), id.swarm.as_str()), ("bitcraze-lab", "cage")); - assert_eq!(id.to_string(), "bitcraze-lab/cage"); - for bad in ["Lab/cage", "/cage", "lab/", "lab/a/b", "lab/.x", "la b/x"] { - assert!(SharedId::parse(bad).is_err(), "{}", bad); - } - } -} diff --git a/src/modules/swarm/store.rs b/src/modules/swarm/store.rs index 52ce56c..e6e73bf 100644 --- a/src/modules/swarm/store.rs +++ b/src/modules/swarm/store.rs @@ -14,13 +14,14 @@ //! ``` //! //! Fields cfcli doesn't know are kept when a file is rewritten, so nothing -//! Swarmkeeper adds later is lost. +//! Swarmkeeper adds later is lost. A swarm may name the lighthouse +//! configuration it flies in (`lighthouse: lab/cage`). -use anyhow::{bail, Context, Result}; +use anyhow::{bail, Result}; use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; use crate::error::CliError; +use crate::modules::documents::{self, Document}; use crate::utils::radio::{self, RadioUri}; /// One swarm file. @@ -29,6 +30,10 @@ pub struct Swarm { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, + /// The lighthouse configuration the swarm flies in: a local `` or a + /// shared `/` (see `cfcli lh config`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lighthouse: Option, // Always written, even when empty: Swarmkeeper requires the field. #[serde(default)] pub units: Vec, @@ -90,21 +95,12 @@ pub fn check_name(name: &str) -> Result<()> { /// Check a swarm ID, which is also a file name. pub fn check_id(id: &str) -> Result<()> { - let valid = !id.is_empty() - && !id.starts_with('.') - && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')); - if !valid { - bail!(CliError::InvalidValue(format!( - "swarm ID '{}': use letters, digits, '-', '_' and '.' (not first)", - id - ))); - } - Ok(()) + documents::check_id::(id) } impl Swarm { pub fn new(name: String, description: Option) -> Self { - Swarm { name, description, units: Vec::new(), extra: serde_yaml::Mapping::new() } + Swarm { name, description, lighthouse: None, units: Vec::new(), extra: serde_yaml::Mapping::new() } } pub fn from_yaml(yaml: &str) -> Result { @@ -202,90 +198,37 @@ impl Swarm { } } -/// The folder holding the swarm files. -pub struct Store { - dir: PathBuf, -} +/// The folder holding the swarm files, next to the cfcli config file. +pub type Store = documents::Store; -impl Store { - /// The store next to the cfcli config file. - pub fn open() -> Result { - let config = confy::get_configuration_file_path("cf-cli", None) - .context("could not find the cfcli config folder")?; - Ok(Store { dir: config.with_file_name("swarms") }) - } +impl Document for Swarm { + const FOLDER: &'static str = "swarms"; + const NOUN: &'static str = "swarm"; + const COMMAND: &'static str = "swarm config"; + const SERVER_COUNT: &'static str = "units"; - /// A store in another folder: the copies of shared swarms, and tests. - pub fn at(dir: PathBuf) -> Self { - Store { dir } + fn from_yaml(yaml: &str) -> Result { + Swarm::from_yaml(yaml) } - pub fn dir(&self) -> &Path { - &self.dir + fn to_yaml(&self) -> Result { + Swarm::to_yaml(self) } - fn path(&self, id: &str) -> PathBuf { - self.dir.join(format!("{}.yaml", id)) + fn summary(&self) -> (String, usize) { + (self.name.clone(), self.units.len()) } - pub fn exists(&self, id: &str) -> bool { - self.path(id).is_file() - } - - /// The IDs of all stored swarms, sorted. - pub fn ids(&self) -> Result> { - let entries = match std::fs::read_dir(&self.dir) { - Ok(entries) => entries, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(e).with_context(|| format!("reading {}", self.dir.display())), + /// The organization of a shared lighthouse configuration. + fn rename_org(&mut self, old: &str, new: &str) -> bool { + let Some((org, config)) = self.lighthouse.as_deref().and_then(|l| l.split_once('/')) else { + return false; }; - let mut ids: Vec = entries - .filter_map(|entry| entry.ok()) - .map(|entry| entry.path()) - .filter(|path| path.extension().is_some_and(|ext| ext == "yaml")) - .filter_map(|path| path.file_stem().map(|stem| stem.to_string_lossy().into_owned())) - .filter(|id| check_id(id).is_ok()) - .collect(); - ids.sort(); - Ok(ids) - } - - /// The file exactly as stored. - pub fn read_raw(&self, id: &str) -> Result { - let path = self.path(id); - match std::fs::read_to_string(&path) { - Ok(yaml) => Ok(yaml), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - bail!(CliError::NotFound(format!("swarm '{}' (see 'cfcli swarm config list')", id))) - } - Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), - } - } - - pub fn load(&self, id: &str) -> Result { - Swarm::from_yaml(&self.read_raw(id)?).with_context(|| format!("in {}", self.path(id).display())) - } - - /// Write a swarm, replacing any old file in one step. - pub fn save(&self, id: &str, swarm: &Swarm) -> Result<()> { - check_id(id)?; - std::fs::create_dir_all(&self.dir).with_context(|| format!("creating {}", self.dir.display()))?; - let path = self.path(id); - let tmp = self.dir.join(format!(".{}.yaml.tmp", id)); - std::fs::write(&tmp, swarm.to_yaml()?).with_context(|| format!("writing {}", tmp.display()))?; - std::fs::rename(&tmp, &path).with_context(|| format!("writing {}", path.display()))?; - Ok(()) - } - - pub fn delete(&self, id: &str) -> Result<()> { - let path = self.path(id); - match std::fs::remove_file(&path) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - bail!(CliError::NotFound(format!("swarm '{}'", id))) - } - Err(e) => Err(e).with_context(|| format!("deleting {}", path.display())), + if org != old { + return false; } + self.lighthouse = Some(format!("{}/{}", new, config)); + true } }