Skip to main content

ryra_protocol/
lib.rs

1//! The typed wire protocol for driving ryra over rpc.
2//!
3//! This crate is the contract, and *only* the contract: pure serde data types,
4//! no dependency on `ryra-core` (the engine). Any client - ryra-api, a control
5//! plane, a third-party tool - can speak it without compiling the engine, which
6//! is what makes ryra-api movable off the box later (it talks to the box's
7//! `ryra rpc` over a transport, depending only on these types).
8//!
9//! The `ryra` binary owns the engine: it deserializes a [`Request`], converts
10//! the protocol-native request payloads into `ryra_core::ops` types, runs them,
11//! and serializes a [`Reply`]. The request payloads here mirror the ops request
12//! structs by shape (not by import), so the engine's internal types stay
13//! engine-private.
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use serde::{Deserialize, Serialize};
18
19// ---- Request payloads (protocol-native; the engine converts to ops::*) ----
20
21/// How a service should be exposed when installed.
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum ExposureRequest {
25    #[default]
26    Loopback,
27    /// A concrete URL, classified by hostname into internal/public.
28    Url(String),
29    /// A pre-derived `*.ts.net` URL (the caller resolved the tailnet identity).
30    Tailscale(String),
31}
32
33/// The kind of auth a service can be wired to.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum AuthKind {
37    Oidc,
38}
39
40/// Whether (and how) to wire a service to the auth provider.
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum AuthRequested {
44    #[default]
45    No,
46    /// The service's first declared auth kind (the `--auth` rule).
47    Yes,
48    /// A specific kind.
49    Kind(AuthKind),
50}
51
52/// Install and start a service.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct AddRequest {
55    /// Registry ref ("forgejo", "acme/forgejo") or a local project path.
56    pub service: String,
57    #[serde(default)]
58    pub exposure: ExposureRequest,
59    #[serde(default)]
60    pub auth: AuthRequested,
61    /// `None` = wire SMTP iff a provider is configured; `Some(true)` errors
62    /// when none exists rather than silently skipping.
63    #[serde(default)]
64    pub smtp: Option<bool>,
65    #[serde(default)]
66    pub backup: bool,
67    #[serde(default)]
68    pub env: BTreeMap<String, String>,
69    #[serde(default)]
70    pub enable_groups: BTreeSet<String>,
71    /// `[[choice]]` selections (`choice -> option`); unset choices use defaults.
72    #[serde(default)]
73    pub choose: BTreeMap<String, String>,
74}
75
76impl AddRequest {
77    /// The simplest install: loopback, no integrations.
78    pub fn new(service: impl Into<String>) -> Self {
79        AddRequest {
80            service: service.into(),
81            exposure: ExposureRequest::default(),
82            auth: AuthRequested::default(),
83            smtp: None,
84            backup: false,
85            env: BTreeMap::new(),
86            enable_groups: BTreeSet::new(),
87            choose: BTreeMap::new(),
88        }
89    }
90}
91
92/// How much to remove.
93#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum RemoveMode {
96    /// Stop + remove quadlets/config but keep data dirs and volumes (orphan).
97    #[default]
98    Preserve,
99    /// Also delete data subdirs and podman named volumes.
100    Purge,
101}
102
103/// Remove a service.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct RemoveRequest {
106    pub service: String,
107    #[serde(default)]
108    pub mode: RemoveMode,
109}
110
111/// Start or stop an installed service.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum Lifecycle {
115    Start,
116    Stop,
117}
118
119/// Start/stop a service (and its sidecars).
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct LifecycleRequest {
122    pub service: String,
123    pub action: Lifecycle,
124}
125
126/// Upgrade a service to the registry's current version.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct UpgradeRequest {
129    pub service: String,
130    /// Re-render even when the diff is empty.
131    #[serde(default)]
132    pub force: bool,
133}
134
135/// An exposure transition for `configure`. `Loopback` means "no public route".
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum ExposureChange {
139    Url(String),
140    Tailscale(String),
141    Loopback,
142}
143
144/// The integration change-set for `configure`. `None`/empty fields leave the
145/// current state untouched; provided fields are the new truth.
146#[derive(Debug, Clone, Default, Serialize, Deserialize)]
147#[serde(default)]
148pub struct Overrides {
149    pub exposure: Option<ExposureChange>,
150    pub smtp: Option<bool>,
151    pub backup: Option<bool>,
152    pub auth: Option<bool>,
153    pub enable_groups: BTreeSet<String>,
154    pub disable_groups: BTreeSet<String>,
155    pub choose: BTreeMap<String, String>,
156    pub env_overrides: BTreeMap<String, String>,
157    /// Re-register the OIDC client even when auth is already on and the URL is
158    /// unchanged (repairs a provider/consumer desync).
159    pub reassert_auth: bool,
160}
161
162/// Re-render an installed service with a changed integration set.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct ConfigureRequest {
165    pub service: String,
166    pub changes: Overrides,
167}
168
169/// One request to the agent. Adjacently tagged so it maps straight onto a
170/// JSON-RPC `method` + `params`: `{"method":"add","params":{...}}`,
171/// `{"method":"list"}`.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(tag = "method", content = "params", rename_all = "snake_case")]
174pub enum Request {
175    /// Install and start a service.
176    Add(AddRequest),
177    /// Remove a service (optionally purging its data).
178    Remove(RemoveRequest),
179    /// Re-render an installed service with a changed integration set.
180    Configure(ConfigureRequest),
181    /// Start or stop an installed service.
182    Lifecycle(LifecycleRequest),
183    /// Upgrade an installed service to the registry's current version.
184    Upgrade(UpgradeRequest),
185    /// List every service (installed + orphan) with live status.
186    List,
187    /// One service's current view.
188    Get { service: String },
189    /// What an upgrade would change for a service (read-only).
190    Diff { service: String },
191    /// The pre-upgrade snapshots available to revert to, newest first.
192    Backups { service: String },
193    /// Restore a service from a pre-upgrade snapshot (latest if `at` is None).
194    Revert {
195        service: String,
196        #[serde(default)]
197        at: Option<String>,
198    },
199    /// Search a registry for installable services (default registry if unset).
200    Search {
201        #[serde(default)]
202        query: Option<String>,
203        #[serde(default)]
204        registry: Option<String>,
205    },
206    /// List the configured registries.
207    Registries,
208    /// Add a custom registry.
209    AddRegistry { name: String, url: String },
210    /// Remove a custom registry.
211    RemoveRegistry { name: String },
212    /// Run the diagnostics ryra-doctor runs.
213    Doctor,
214    /// Take a backup snapshot of a (backup-enabled) service.
215    Backup { service: String },
216    /// Restore a service's data from a restic snapshot ("latest" for newest).
217    Restore { service: String, snapshot: String },
218    /// List a service's restic data snapshots, newest first (`ryra backup list`).
219    Snapshots { service: String },
220    /// The effective backup configuration + enrolled services
221    /// (`ryra backup status`).
222    BackupStatus,
223    /// Point backups at a backend: init the restic repo and persist `[backup]`
224    /// (`ryra backup config`). `password` is the restic key; when absent the
225    /// engine reuses the existing key or generates a fresh one.
226    ConfigureBackup {
227        backend: BackupBackendSpec,
228        #[serde(default)]
229        password: Option<String>,
230    },
231    /// Opt a service in or out of backups.
232    SetBackupEnrolled { service: String, enabled: bool },
233    /// The installable env/group/choice schema for a registry service
234    /// (default registry if `registry` is unset).
235    ServiceDef {
236        service: String,
237        #[serde(default)]
238        registry: Option<String>,
239    },
240    /// The configure view (schema + current selections + `.env`) for an
241    /// installed service.
242    ConfigureView { service: String },
243    /// Propagate the current global config into installed services
244    /// (`ryra config --apply`). Empty `services` = every installed service
245    /// whose env would change; `dry_run` previews without writing/restarting.
246    Reconcile {
247        #[serde(default)]
248        services: Vec<String>,
249        #[serde(default)]
250        dry_run: bool,
251    },
252    /// Discover the registry's test suites (`ryra test search`).
253    ListTests,
254    /// Run one registry test by name on the host (`ryra test <name>`).
255    RunTest { name: String },
256    /// Local test sandbox state: installed services + last results
257    /// (`ryra test list`).
258    TestState,
259    /// Delete stored results for one test, or all tests when `name` is None
260    /// (`ryra test remove`).
261    RemoveTestResults {
262        #[serde(default)]
263        name: Option<String>,
264    },
265}
266
267/// The result of a backup run.
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct BackupOutcome {
270    pub service: String,
271    /// Paths included in the snapshot.
272    pub paths: usize,
273}
274
275/// The result of a restore.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct RestoreOutcome {
278    pub service: String,
279    /// The snapshot restored ("latest" when none was specified).
280    pub snapshot: String,
281}
282
283/// Where backups are stored, as a client describes one when configuring.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(rename_all = "snake_case")]
286pub enum BackupBackendSpec {
287    /// A local restic repo path (no off-box protection; rarely what you want).
288    Local { path: String },
289    /// Any S3-compatible object store (MinIO, AWS S3, B2, R2, Wasabi).
290    S3 {
291        endpoint: String,
292        bucket: String,
293        access_key_id: String,
294        secret_access_key: String,
295        #[serde(default)]
296        prefix: Option<String>,
297    },
298    /// Ryra-managed: the box holds no storage keys; it vends short-lived,
299    /// account-scoped S3 credentials per backup run. Requires an active managed
300    /// backup plan (configuring without one fails at credential-vend time).
301    Managed,
302}
303
304/// One restic data snapshot (`ryra backup list`).
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct SnapshotView {
307    /// Short restic snapshot id; pass back as the restore snapshot.
308    pub id: String,
309    /// RFC3339 timestamp the snapshot was taken.
310    pub time: String,
311    /// Restic tags (e.g. `service:foo`, `manifest_sha:...`).
312    pub tags: Vec<String>,
313}
314
315/// The effective backup configuration plus enrolled services
316/// (`ryra backup status`).
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct BackupStatusView {
319    /// `[backup]` is configured (env-seeded, CLI, or manual).
320    pub configured: bool,
321    /// Human label for the backend, e.g. "S3: my-bucket (...)". None when unset.
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub backend_label: Option<String>,
324    /// Services enrolled in backups (`metadata.backup_enabled`).
325    pub enrolled: Vec<String>,
326}
327
328/// One env key a reconcile would change in a service's `.env`.
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct EnvKeyChangeView {
331    pub key: String,
332    /// On-disk value, or `None` when the key isn't present yet.
333    pub from: Option<String>,
334    pub to: String,
335    /// True when the key name looks sensitive (a client masks it for display).
336    pub secret: bool,
337}
338
339/// What a reconcile would (or did) do to one installed service.
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct ReconcilePlanView {
342    pub service: String,
343    pub changes: Vec<EnvKeyChangeView>,
344}
345
346/// The outcome of propagating the global config into installed services.
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct ReconcileOutcome {
349    /// Affected services and their env diffs (the preview, or what was applied).
350    pub plans: Vec<ReconcilePlanView>,
351    /// How many services were updated and restarted (0 on a dry run).
352    pub applied: usize,
353}
354
355/// One installable service from a registry search.
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct SearchHit {
358    pub name: String,
359    pub description: String,
360    pub installed: bool,
361    /// Integrations the service supports (e.g. "oidc", "smtp").
362    pub supports: Vec<String>,
363    /// Recommended RAM in MB from the manifest, when declared. Lets callers
364    /// warn before an install would overcommit the machine's memory.
365    #[serde(default)]
366    pub recommended_ram_mb: Option<u64>,
367}
368
369/// A configured registry.
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct RegistryInfo {
372    pub name: String,
373    pub url: String,
374    pub service_count: usize,
375}
376
377/// Severity of a doctor finding.
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
379#[serde(rename_all = "snake_case")]
380pub enum Severity {
381    /// Blocks installs outright.
382    Blocker,
383    /// Service runs but the user probably wants to fix it.
384    Warning,
385    /// Informational.
386    Info,
387}
388
389/// One diagnostic finding.
390#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct DoctorIssue {
392    /// Stable machine-readable id for the issue variant.
393    pub code: String,
394    pub severity: Severity,
395    /// Full human-readable message, including the suggested fix (byte-for-byte
396    /// what `ryra doctor` prints).
397    pub message: String,
398    /// The service this issue is scoped to, when service-specific.
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub service: Option<String>,
401}
402
403/// How one file differs between the registry render and disk.
404#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(rename_all = "snake_case")]
406pub enum DiffKind {
407    Unchanged,
408    Modified,
409    /// Hand-edited; blocks a plain upgrade without force.
410    Drift,
411    Added,
412    Removed,
413}
414
415/// One changed file in a [`DiffView`].
416#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct DiffEntry {
418    pub path: String,
419    pub kind: DiffKind,
420}
421
422/// An env var the registry expects that the install is missing.
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct EnvAddition {
425    pub key: String,
426    /// Registry env kind (default / prompted / required), as a string.
427    pub kind: String,
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub prompt: Option<String>,
430}
431
432/// What an upgrade would change for a service.
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct DiffView {
435    pub service: String,
436    /// Anything (file or env or stale source) would change on upgrade.
437    pub upgrade_available: bool,
438    /// Hand-edited files would block a plain upgrade (needs force).
439    pub blocked_by_drift: bool,
440    /// Native source changed since the process started (rebuild would ship it).
441    pub source_stale: bool,
442    /// Per-file changes; omits unchanged files.
443    pub entries: Vec<DiffEntry>,
444    /// Env vars the registry expects but the `.env` is missing.
445    pub env_additions: Vec<EnvAddition>,
446}
447
448/// One restorable pre-upgrade snapshot.
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct BackupSnapshotView {
451    /// `YYYY-MM-DDTHH-MM-SSZ`; pass back as `at` to revert to exactly this one.
452    pub timestamp: String,
453}
454
455/// The result of a revert.
456#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct RevertOutcome {
458    pub service: String,
459    /// The snapshot timestamp restored.
460    pub timestamp: String,
461    pub files_restored: usize,
462    pub files_deleted: usize,
463}
464
465/// Live run state of a service.
466#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
467#[serde(rename_all = "snake_case")]
468pub enum ServiceState {
469    Running,
470    Stopped,
471    /// Install/start is in flight: the unit's start job is still running
472    /// (image pull, container create, health check) so it reports
473    /// `activating`, not yet `active`. A transient state during `ryra add`.
474    Installing,
475    /// Removed, but its data is preserved on disk.
476    Removed,
477}
478
479/// A service as seen over the wire: the stable, serde projection of an on-disk
480/// installed service plus its live status.
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct ServiceView {
483    pub name: String,
484    pub state: ServiceState,
485    /// The URL a user reaches the service at, if it has one.
486    #[serde(skip_serializing_if = "Option::is_none")]
487    pub url: Option<String>,
488    /// Allocated host ports (`port_name -> host_port`).
489    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
490    pub ports: BTreeMap<String, u16>,
491    /// Registry the service came from.
492    #[serde(skip_serializing_if = "Option::is_none")]
493    pub registry: Option<String>,
494    /// Installed version.
495    #[serde(skip_serializing_if = "Option::is_none")]
496    pub version: Option<String>,
497    /// A newer version is available in the registry.
498    #[serde(default)]
499    pub upgrade_available: bool,
500}
501
502/// The outcome of a mutating operation: the affected service's fresh view plus
503/// what the apply did. `applied` is the number of steps/changes executed (0 =
504/// nothing to do); `destructive` is true when the change deletes data.
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct ApplyOutcome {
507    pub service: ServiceView,
508    pub applied: usize,
509    #[serde(default)]
510    pub destructive: bool,
511}
512
513// ---- Service-definition views (the install / configure forms) -------------
514
515/// How a registry env var is treated: a `default` value, a `prompted` one the
516/// user may override, or a `required` one they must supply.
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
518#[serde(rename_all = "snake_case")]
519pub enum EnvKindView {
520    Default,
521    Prompted,
522    Required,
523}
524
525/// One env var as a form renders it: enough to label it, decide whether it
526/// needs input, and show whether the value is auto-generated.
527#[derive(Debug, Clone, Serialize, Deserialize)]
528pub struct EnvVarView {
529    pub name: String,
530    pub kind: EnvKindView,
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub prompt: Option<String>,
533    /// Value format: "string", "hex", "base64", "base64_url", "uuid", "jwt_hs256".
534    pub format: String,
535    /// The value comes from a `{{secret.*}}` template, so it's auto-generated.
536    pub generated: bool,
537    /// The declared value is empty (a `prompted` var with no default needs input).
538    pub value_empty: bool,
539}
540
541/// An optional, named group of env vars, enabled together.
542#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct EnvGroupView {
544    pub name: String,
545    pub prompt: String,
546    pub env: Vec<EnvVarView>,
547}
548
549/// One alternative within a [`ChoiceView`].
550#[derive(Debug, Clone, Serialize, Deserialize)]
551pub struct ChoiceOptionView {
552    pub name: String,
553    #[serde(skip_serializing_if = "Option::is_none")]
554    pub label: Option<String>,
555    pub env: Vec<EnvVarView>,
556}
557
558/// A single-select `[[choice]]`: pick exactly one option.
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct ChoiceView {
561    pub name: String,
562    pub prompt: String,
563    pub default: String,
564    pub options: Vec<ChoiceOptionView>,
565}
566
567/// A service definition's installable schema, as the install picker renders it.
568#[derive(Debug, Clone, Serialize, Deserialize)]
569pub struct ServiceDefView {
570    pub name: String,
571    pub env: Vec<EnvVarView>,
572    pub env_groups: Vec<EnvGroupView>,
573    pub choices: Vec<ChoiceView>,
574}
575
576/// The configure view for an installed service: its rendered schema plus the
577/// selections and `.env` values currently on disk, so a form can pre-fill.
578#[derive(Debug, Clone, Serialize, Deserialize)]
579pub struct ConfigureView {
580    pub name: String,
581    pub def: ServiceDefView,
582    /// Currently selected option per `[[choice]]` (`choice -> option`).
583    pub selected_choices: BTreeMap<String, String>,
584    /// Currently enabled optional groups.
585    pub enabled_groups: Vec<String>,
586    /// Current `.env` values, so prompted/required fields show what's set.
587    pub current_env: BTreeMap<String, String>,
588}
589
590/// One discoverable registry test (`ryra test search`).
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct RegistryTestView {
593    pub name: String,
594    /// `"simple"` (setup then assert) or `"lifecycle"` (interleaved steps).
595    pub kind: String,
596    pub services: Vec<String>,
597    pub step_count: usize,
598    pub step_kinds: Vec<String>,
599    pub needs_browser: bool,
600    pub requires_sudo: bool,
601}
602
603/// The outcome of running one test (`ryra test <name>`).
604#[derive(Debug, Clone, Serialize, Deserialize)]
605pub struct TestRunView {
606    pub name: String,
607    pub passed: bool,
608    pub duration_secs: f64,
609    /// `"passed"` / `"skipped"` / a failure message.
610    pub outcome: String,
611    pub events: Vec<TestEventView>,
612}
613
614/// One step/assertion within a test run.
615#[derive(Debug, Clone, Serialize, Deserialize)]
616pub struct TestEventView {
617    pub description: String,
618    /// `"step"` or `"assertion"`.
619    pub kind: String,
620    pub passed: bool,
621    pub skipped: bool,
622    pub error: Option<String>,
623    pub duration_secs: f64,
624    pub stdout: String,
625    pub stderr: String,
626}
627
628/// Local test sandbox state: where it lives + the last stored results.
629#[derive(Debug, Clone, Serialize, Deserialize)]
630pub struct TestStateView {
631    pub sandbox_path: String,
632    pub tests: Vec<TestResultEntryView>,
633}
634
635/// One stored test result (from a prior run).
636#[derive(Debug, Clone, Serialize, Deserialize)]
637pub struct TestResultEntryView {
638    pub name: String,
639    pub status: String,
640    pub duration_ms: u64,
641    pub timestamp: u64,
642    pub has_playwright: bool,
643}
644
645/// The payload of a successful response.
646#[derive(Debug, Clone, Serialize, Deserialize)]
647#[serde(rename_all = "snake_case")]
648pub enum Response {
649    /// `add` / `configure` / `lifecycle` / `upgrade`.
650    Applied(ApplyOutcome),
651    /// `get`.
652    Service(ServiceView),
653    /// `list`.
654    Services(Vec<ServiceView>),
655    /// `diff`.
656    Diff(DiffView),
657    /// `backups`.
658    Backups(Vec<BackupSnapshotView>),
659    /// `revert`.
660    Revert(RevertOutcome),
661    /// `search`.
662    SearchResults(Vec<SearchHit>),
663    /// `registries`.
664    Registries(Vec<RegistryInfo>),
665    /// `doctor`.
666    Doctor(Vec<DoctorIssue>),
667    /// `backup`.
668    Backup(BackupOutcome),
669    /// `restore`.
670    Restore(RestoreOutcome),
671    /// `snapshots`.
672    Snapshots(Vec<SnapshotView>),
673    /// `backup_status`.
674    BackupStatus(BackupStatusView),
675    /// `service_def`.
676    ServiceDef(ServiceDefView),
677    /// `configure_view`.
678    ConfigureView(ConfigureView),
679    /// `reconcile`.
680    Reconcile(ReconcileOutcome),
681    /// `list_tests`.
682    Tests(Vec<RegistryTestView>),
683    /// `run_test`.
684    TestRun(TestRunView),
685    /// `test_state`.
686    TestState(TestStateView),
687    /// `remove` / `add_registry` / `remove_registry` / `remove_test_results`.
688    Done,
689}
690
691/// What `ryra rpc` writes to stdout: exactly one of these per request, then it
692/// exits.
693#[derive(Debug, Clone, Serialize, Deserialize)]
694#[serde(rename_all = "snake_case")]
695pub enum Reply {
696    Ok(Response),
697    Error(RpcError),
698}
699
700/// A structured error, mappable to a JSON-RPC error object.
701#[derive(Debug, Clone, Serialize, Deserialize)]
702pub struct RpcError {
703    pub code: ErrorCode,
704    pub message: String,
705}
706
707/// Coarse error categories, so a client can branch without string-matching.
708#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
709#[serde(rename_all = "snake_case")]
710pub enum ErrorCode {
711    BadRequest,
712    NotFound,
713    Conflict,
714    Internal,
715}
716
717impl RpcError {
718    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
719        RpcError {
720            code,
721            message: message.into(),
722        }
723    }
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729
730    #[test]
731    fn request_maps_to_method_and_params() {
732        let req = Request::Add(AddRequest::new("forgejo"));
733        let v = serde_json::to_value(&req).unwrap();
734        assert_eq!(v["method"], "add");
735        assert_eq!(v["params"]["service"], "forgejo");
736    }
737
738    #[test]
739    fn unit_request_has_no_params() {
740        let v = serde_json::to_value(Request::List).unwrap();
741        assert_eq!(v["method"], "list");
742        assert!(v.get("params").is_none());
743    }
744
745    #[test]
746    fn service_view_round_trips_and_omits_empties() {
747        let view = ServiceView {
748            name: "forgejo".to_string(),
749            state: ServiceState::Running,
750            url: Some("https://forgejo.example.com".to_string()),
751            ports: BTreeMap::new(),
752            registry: None,
753            version: None,
754            upgrade_available: false,
755        };
756        let v = serde_json::to_value(&view).unwrap();
757        assert!(v.get("ports").is_none());
758        assert_eq!(v["state"], "running");
759        let back: ServiceView = serde_json::from_value(v).unwrap();
760        assert_eq!(back.name, "forgejo");
761        assert_eq!(back.state, ServiceState::Running);
762    }
763}