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 configure`). `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 configure --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}
299
300/// One restic data snapshot (`ryra backup list`).
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct SnapshotView {
303    /// Short restic snapshot id; pass back as the restore snapshot.
304    pub id: String,
305    /// RFC3339 timestamp the snapshot was taken.
306    pub time: String,
307    /// Restic tags (e.g. `service:foo`, `manifest_sha:...`).
308    pub tags: Vec<String>,
309}
310
311/// The effective backup configuration plus enrolled services
312/// (`ryra backup status`).
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct BackupStatusView {
315    /// `[backup]` is configured (env-seeded, CLI, or manual).
316    pub configured: bool,
317    /// Human label for the backend, e.g. "S3: my-bucket (...)". None when unset.
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub backend_label: Option<String>,
320    /// Services enrolled in backups (`metadata.backup_enabled`).
321    pub enrolled: Vec<String>,
322}
323
324/// One env key a reconcile would change in a service's `.env`.
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct EnvKeyChangeView {
327    pub key: String,
328    /// On-disk value, or `None` when the key isn't present yet.
329    pub from: Option<String>,
330    pub to: String,
331    /// True when the key name looks sensitive (a client masks it for display).
332    pub secret: bool,
333}
334
335/// What a reconcile would (or did) do to one installed service.
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct ReconcilePlanView {
338    pub service: String,
339    pub changes: Vec<EnvKeyChangeView>,
340}
341
342/// The outcome of propagating the global config into installed services.
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct ReconcileOutcome {
345    /// Affected services and their env diffs (the preview, or what was applied).
346    pub plans: Vec<ReconcilePlanView>,
347    /// How many services were updated and restarted (0 on a dry run).
348    pub applied: usize,
349}
350
351/// One installable service from a registry search.
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct SearchHit {
354    pub name: String,
355    pub description: String,
356    pub installed: bool,
357    /// Integrations the service supports (e.g. "oidc", "smtp").
358    pub supports: Vec<String>,
359    /// Recommended RAM in MB from the manifest, when declared. Lets callers
360    /// warn before an install would overcommit the machine's memory.
361    #[serde(default)]
362    pub recommended_ram_mb: Option<u64>,
363}
364
365/// A configured registry.
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct RegistryInfo {
368    pub name: String,
369    pub url: String,
370    pub service_count: usize,
371}
372
373/// Severity of a doctor finding.
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(rename_all = "snake_case")]
376pub enum Severity {
377    /// Blocks installs outright.
378    Blocker,
379    /// Service runs but the user probably wants to fix it.
380    Warning,
381    /// Informational.
382    Info,
383}
384
385/// One diagnostic finding.
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct DoctorIssue {
388    /// Stable machine-readable id for the issue variant.
389    pub code: String,
390    pub severity: Severity,
391    /// Full human-readable message, including the suggested fix (byte-for-byte
392    /// what `ryra doctor` prints).
393    pub message: String,
394    /// The service this issue is scoped to, when service-specific.
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub service: Option<String>,
397}
398
399/// How one file differs between the registry render and disk.
400#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
401#[serde(rename_all = "snake_case")]
402pub enum DiffKind {
403    Unchanged,
404    Modified,
405    /// Hand-edited; blocks a plain upgrade without force.
406    Drift,
407    Added,
408    Removed,
409}
410
411/// One changed file in a [`DiffView`].
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct DiffEntry {
414    pub path: String,
415    pub kind: DiffKind,
416}
417
418/// An env var the registry expects that the install is missing.
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct EnvAddition {
421    pub key: String,
422    /// Registry env kind (default / prompted / required), as a string.
423    pub kind: String,
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub prompt: Option<String>,
426}
427
428/// What an upgrade would change for a service.
429#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct DiffView {
431    pub service: String,
432    /// Anything (file or env or stale source) would change on upgrade.
433    pub upgrade_available: bool,
434    /// Hand-edited files would block a plain upgrade (needs force).
435    pub blocked_by_drift: bool,
436    /// Native source changed since the process started (rebuild would ship it).
437    pub source_stale: bool,
438    /// Per-file changes; omits unchanged files.
439    pub entries: Vec<DiffEntry>,
440    /// Env vars the registry expects but the `.env` is missing.
441    pub env_additions: Vec<EnvAddition>,
442}
443
444/// One restorable pre-upgrade snapshot.
445#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct BackupSnapshotView {
447    /// `YYYY-MM-DDTHH-MM-SSZ`; pass back as `at` to revert to exactly this one.
448    pub timestamp: String,
449}
450
451/// The result of a revert.
452#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct RevertOutcome {
454    pub service: String,
455    /// The snapshot timestamp restored.
456    pub timestamp: String,
457    pub files_restored: usize,
458    pub files_deleted: usize,
459}
460
461/// Live run state of a service.
462#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
463#[serde(rename_all = "snake_case")]
464pub enum ServiceState {
465    Running,
466    Stopped,
467    /// Removed, but its data is preserved on disk.
468    Removed,
469}
470
471/// A service as seen over the wire: the stable, serde projection of an on-disk
472/// installed service plus its live status.
473#[derive(Debug, Clone, Serialize, Deserialize)]
474pub struct ServiceView {
475    pub name: String,
476    pub state: ServiceState,
477    /// The URL a user reaches the service at, if it has one.
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub url: Option<String>,
480    /// Allocated host ports (`port_name -> host_port`).
481    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
482    pub ports: BTreeMap<String, u16>,
483    /// Registry the service came from.
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub registry: Option<String>,
486    /// Installed version.
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub version: Option<String>,
489    /// A newer version is available in the registry.
490    #[serde(default)]
491    pub upgrade_available: bool,
492}
493
494/// The outcome of a mutating operation: the affected service's fresh view plus
495/// what the apply did. `applied` is the number of steps/changes executed (0 =
496/// nothing to do); `destructive` is true when the change deletes data.
497#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct ApplyOutcome {
499    pub service: ServiceView,
500    pub applied: usize,
501    #[serde(default)]
502    pub destructive: bool,
503}
504
505// ---- Service-definition views (the install / configure forms) -------------
506
507/// How a registry env var is treated: a `default` value, a `prompted` one the
508/// user may override, or a `required` one they must supply.
509#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
510#[serde(rename_all = "snake_case")]
511pub enum EnvKindView {
512    Default,
513    Prompted,
514    Required,
515}
516
517/// One env var as a form renders it: enough to label it, decide whether it
518/// needs input, and show whether the value is auto-generated.
519#[derive(Debug, Clone, Serialize, Deserialize)]
520pub struct EnvVarView {
521    pub name: String,
522    pub kind: EnvKindView,
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub prompt: Option<String>,
525    /// Value format: "string", "hex", "base64", "base64_url", "uuid", "jwt_hs256".
526    pub format: String,
527    /// The value comes from a `{{secret.*}}` template, so it's auto-generated.
528    pub generated: bool,
529    /// The declared value is empty (a `prompted` var with no default needs input).
530    pub value_empty: bool,
531}
532
533/// An optional, named group of env vars, enabled together.
534#[derive(Debug, Clone, Serialize, Deserialize)]
535pub struct EnvGroupView {
536    pub name: String,
537    pub prompt: String,
538    pub env: Vec<EnvVarView>,
539}
540
541/// One alternative within a [`ChoiceView`].
542#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct ChoiceOptionView {
544    pub name: String,
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub label: Option<String>,
547    pub env: Vec<EnvVarView>,
548}
549
550/// A single-select `[[choice]]`: pick exactly one option.
551#[derive(Debug, Clone, Serialize, Deserialize)]
552pub struct ChoiceView {
553    pub name: String,
554    pub prompt: String,
555    pub default: String,
556    pub options: Vec<ChoiceOptionView>,
557}
558
559/// A service definition's installable schema, as the install picker renders it.
560#[derive(Debug, Clone, Serialize, Deserialize)]
561pub struct ServiceDefView {
562    pub name: String,
563    pub env: Vec<EnvVarView>,
564    pub env_groups: Vec<EnvGroupView>,
565    pub choices: Vec<ChoiceView>,
566}
567
568/// The configure view for an installed service: its rendered schema plus the
569/// selections and `.env` values currently on disk, so a form can pre-fill.
570#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct ConfigureView {
572    pub name: String,
573    pub def: ServiceDefView,
574    /// Currently selected option per `[[choice]]` (`choice -> option`).
575    pub selected_choices: BTreeMap<String, String>,
576    /// Currently enabled optional groups.
577    pub enabled_groups: Vec<String>,
578    /// Current `.env` values, so prompted/required fields show what's set.
579    pub current_env: BTreeMap<String, String>,
580}
581
582/// One discoverable registry test (`ryra test search`).
583#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct RegistryTestView {
585    pub name: String,
586    /// `"simple"` (setup then assert) or `"lifecycle"` (interleaved steps).
587    pub kind: String,
588    pub services: Vec<String>,
589    pub step_count: usize,
590    pub step_kinds: Vec<String>,
591    pub needs_browser: bool,
592    pub requires_sudo: bool,
593}
594
595/// The outcome of running one test (`ryra test <name>`).
596#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct TestRunView {
598    pub name: String,
599    pub passed: bool,
600    pub duration_secs: f64,
601    /// `"passed"` / `"skipped"` / a failure message.
602    pub outcome: String,
603    pub events: Vec<TestEventView>,
604}
605
606/// One step/assertion within a test run.
607#[derive(Debug, Clone, Serialize, Deserialize)]
608pub struct TestEventView {
609    pub description: String,
610    /// `"step"` or `"assertion"`.
611    pub kind: String,
612    pub passed: bool,
613    pub skipped: bool,
614    pub error: Option<String>,
615    pub duration_secs: f64,
616    pub stdout: String,
617    pub stderr: String,
618}
619
620/// Local test sandbox state: where it lives + the last stored results.
621#[derive(Debug, Clone, Serialize, Deserialize)]
622pub struct TestStateView {
623    pub sandbox_path: String,
624    pub tests: Vec<TestResultEntryView>,
625}
626
627/// One stored test result (from a prior run).
628#[derive(Debug, Clone, Serialize, Deserialize)]
629pub struct TestResultEntryView {
630    pub name: String,
631    pub status: String,
632    pub duration_ms: u64,
633    pub timestamp: u64,
634    pub has_playwright: bool,
635}
636
637/// The payload of a successful response.
638#[derive(Debug, Clone, Serialize, Deserialize)]
639#[serde(rename_all = "snake_case")]
640pub enum Response {
641    /// `add` / `configure` / `lifecycle` / `upgrade`.
642    Applied(ApplyOutcome),
643    /// `get`.
644    Service(ServiceView),
645    /// `list`.
646    Services(Vec<ServiceView>),
647    /// `diff`.
648    Diff(DiffView),
649    /// `backups`.
650    Backups(Vec<BackupSnapshotView>),
651    /// `revert`.
652    Revert(RevertOutcome),
653    /// `search`.
654    SearchResults(Vec<SearchHit>),
655    /// `registries`.
656    Registries(Vec<RegistryInfo>),
657    /// `doctor`.
658    Doctor(Vec<DoctorIssue>),
659    /// `backup`.
660    Backup(BackupOutcome),
661    /// `restore`.
662    Restore(RestoreOutcome),
663    /// `snapshots`.
664    Snapshots(Vec<SnapshotView>),
665    /// `backup_status`.
666    BackupStatus(BackupStatusView),
667    /// `service_def`.
668    ServiceDef(ServiceDefView),
669    /// `configure_view`.
670    ConfigureView(ConfigureView),
671    /// `reconcile`.
672    Reconcile(ReconcileOutcome),
673    /// `list_tests`.
674    Tests(Vec<RegistryTestView>),
675    /// `run_test`.
676    TestRun(TestRunView),
677    /// `test_state`.
678    TestState(TestStateView),
679    /// `remove` / `add_registry` / `remove_registry` / `remove_test_results`.
680    Done,
681}
682
683/// What `ryra rpc` writes to stdout: exactly one of these per request, then it
684/// exits.
685#[derive(Debug, Clone, Serialize, Deserialize)]
686#[serde(rename_all = "snake_case")]
687pub enum Reply {
688    Ok(Response),
689    Error(RpcError),
690}
691
692/// A structured error, mappable to a JSON-RPC error object.
693#[derive(Debug, Clone, Serialize, Deserialize)]
694pub struct RpcError {
695    pub code: ErrorCode,
696    pub message: String,
697}
698
699/// Coarse error categories, so a client can branch without string-matching.
700#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
701#[serde(rename_all = "snake_case")]
702pub enum ErrorCode {
703    BadRequest,
704    NotFound,
705    Conflict,
706    Internal,
707}
708
709impl RpcError {
710    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
711        RpcError {
712            code,
713            message: message.into(),
714        }
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    #[test]
723    fn request_maps_to_method_and_params() {
724        let req = Request::Add(AddRequest::new("forgejo"));
725        let v = serde_json::to_value(&req).unwrap();
726        assert_eq!(v["method"], "add");
727        assert_eq!(v["params"]["service"], "forgejo");
728    }
729
730    #[test]
731    fn unit_request_has_no_params() {
732        let v = serde_json::to_value(Request::List).unwrap();
733        assert_eq!(v["method"], "list");
734        assert!(v.get("params").is_none());
735    }
736
737    #[test]
738    fn service_view_round_trips_and_omits_empties() {
739        let view = ServiceView {
740            name: "forgejo".to_string(),
741            state: ServiceState::Running,
742            url: Some("https://forgejo.example.com".to_string()),
743            ports: BTreeMap::new(),
744            registry: None,
745            version: None,
746            upgrade_available: false,
747        };
748        let v = serde_json::to_value(&view).unwrap();
749        assert!(v.get("ports").is_none());
750        assert_eq!(v["state"], "running");
751        let back: ServiceView = serde_json::from_value(v).unwrap();
752        assert_eq!(back.name, "forgejo");
753        assert_eq!(back.state, ServiceState::Running);
754    }
755}