Skip to main content

mitosis/
protocol.rs

1use crate::error::MitosisError;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5pub const PROTOCOL_VERSION: u32 = 3;
6
7#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
8#[serde(tag = "kind", rename_all = "snake_case")]
9pub enum TemplateSource {
10    Url {
11        url: String,
12    },
13    /// A physical copy of a running server made with `pg_basebackup`.
14    Basebackup {
15        url: String,
16    },
17    Seed {
18        command: String,
19        /// Folder the command runs in; the daemon's own cwd when absent.
20        #[serde(default)]
21        cwd: Option<PathBuf>,
22        /// The caller's `PATH`, so the command finds the same tools as in the caller's shell;
23        /// the daemon's own `PATH` when absent.
24        #[serde(default)]
25        path: Option<String>,
26    },
27    /// Made by `promote` from a running clone; there is no source to rebuild it from.
28    Promoted {
29        clone_id: String,
30    },
31    /// A ZFS snapshot of a live Postgres data directory: the root of `dataset` (ZFS back end only).
32    Running {
33        dataset: String,
34    },
35}
36
37/// How a template generation was made.
38#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum GenKind {
41    Full,
42    Layered,
43    Promoted,
44    Upgraded,
45}
46
47#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
48#[serde(rename_all = "snake_case")]
49pub enum RefreshMode {
50    #[default]
51    Full,
52    Layered,
53}
54
55#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
56#[serde(rename_all = "snake_case")]
57pub enum OnStale {
58    #[default]
59    Warn,
60    Refresh,
61}
62
63/// A migration command run against a template, in `cwd`, with the caller's `PATH`.
64#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
65pub struct MigrateCmd {
66    pub command: String,
67    pub cwd: PathBuf,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub path: Option<String>,
70}
71
72#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
73pub struct GenerationInfo {
74    pub gen: u32,
75    pub kind: GenKind,
76    pub built_at: i64,
77    pub pg_major: u32,
78    pub size_bytes: u64,
79    pub current: bool,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub from_clone: Option<String>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub from_gen: Option<u32>,
84}
85
86/// Template settings a request may change; `None` / `false` leaves a setting as it is.
87/// An `_off` flag turns its setting off and must not come with a value.
88#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
89pub struct TemplateSettings {
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub keep: Option<u32>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub every_secs: Option<u64>,
94    #[serde(default)]
95    pub every_off: bool,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub every_mode: Option<RefreshMode>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub migrate: Option<MigrateCmd>,
100    #[serde(default)]
101    pub migrate_off: bool,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub auto_migrate: Option<bool>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub on_stale: Option<OnStale>,
106    /// Masking file, as an absolute path.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub mask: Option<PathBuf>,
109    #[serde(default)]
110    pub mask_off: bool,
111    /// The git branch the base template's recorded checkout follows.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub base_branch: Option<String>,
114    /// Forget the base branch: layered builds then run on any branch, as before v1.0.
115    #[serde(default)]
116    pub base_branch_off: bool,
117}
118
119#[derive(Serialize, Deserialize, Clone, Debug)]
120pub struct TemplateInitArgs {
121    pub name: String,
122    pub source: TemplateSource,
123    pub database: Option<String>,
124    pub migrations: Option<PathBuf>,
125    pub max_age_secs: Option<u64>,
126    pub pg_major: Option<u32>,
127    /// Applied with the first generation.
128    #[serde(flatten)]
129    pub settings: TemplateSettings,
130}
131
132#[derive(Serialize, Deserialize, Clone, Debug)]
133pub struct TemplateSetArgs {
134    pub name: String,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub max_age_secs: Option<u64>,
137    #[serde(flatten)]
138    pub settings: TemplateSettings,
139    /// Clears `paused`, so automatic builds run again.
140    #[serde(default)]
141    pub resume: bool,
142}
143
144#[derive(Serialize, Deserialize, Clone, Debug)]
145pub struct CloneCreateArgs {
146    pub template: String,
147    pub name: Option<String>,
148    pub ttl_secs: u64,
149    pub durable: bool,
150    pub parent_pid: Option<i32>,
151    /// Destroy a running clone of this name first (its reservation is kept), then create it.
152    #[serde(default)]
153    pub fresh: bool,
154    /// Needs a name; no parent, no TTL, and `destroy` needs `force`.
155    #[serde(default)]
156    pub protect: bool,
157    /// A `.env` entry the client will point at the clone; the daemon records it so a destroy can
158    /// put `previous` back.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub env: Option<EnvTarget>,
161    /// How long to wait for room under the limits; `None` uses the daemon's `queue_wait`.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub wait_secs: Option<u64>,
164    /// Clone the template built for this git branch (`<template>~<branch>`) instead.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub branch: Option<BranchArgs>,
167}
168
169/// The caller's git checkout for `clone --branch`; the client fills it.
170#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
171pub struct BranchArgs {
172    /// The branch name as git shows it.
173    pub name: String,
174    /// The root of the caller's git work tree (`git rev-parse --show-toplevel`).
175    pub repo_root: PathBuf,
176    /// The caller's `PATH`, for the migrate command.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub path: Option<String>,
179}
180
181/// One variable in one `.env` file that holds a clone's URL.
182#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
183pub struct EnvTarget {
184    /// Absolute path.
185    pub file: PathBuf,
186    pub var: String,
187    /// The value the variable had before (`None`: the line did not exist).
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub previous: Option<String>,
190}
191
192/// A `.env` restore the daemon could not do (e.g. a read-only file); a client finishes it.
193#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
194pub struct PendingRestore {
195    pub id: i64,
196    pub file: PathBuf,
197    pub var: String,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub previous: Option<String>,
200    /// The clone URL the file must still hold for the restore to apply.
201    pub url: String,
202}
203
204#[derive(Serialize, Deserialize, Clone, Debug)]
205#[serde(tag = "type", rename_all = "snake_case")]
206pub enum Request {
207    TemplateInit(TemplateInitArgs),
208    TemplateRefresh {
209        name: String,
210        /// The refreshing caller's `PATH`; for a seed template it replaces the recorded one.
211        #[serde(default, skip_serializing_if = "Option::is_none")]
212        path: Option<String>,
213        /// Copy the current generation and run only the migrate command on it.
214        #[serde(default)]
215        layered: bool,
216    },
217    TemplateSet(TemplateSetArgs),
218    TemplateList,
219    TemplateHistory {
220        name: String,
221    },
222    /// Points the template at generation `to`, or the newest one older than the current.
223    TemplateRollback {
224        name: String,
225        #[serde(default, skip_serializing_if = "Option::is_none")]
226        to: Option<u32>,
227    },
228    /// Moves the template to Postgres `to` with pg_upgrade: a new generation, or with `as_name`
229    /// a new template (same database and settings, schedule off).
230    TemplateUpgrade {
231        name: String,
232        to: u32,
233        #[serde(default, skip_serializing_if = "Option::is_none")]
234        as_name: Option<String>,
235    },
236    CloneCreate(CloneCreateArgs),
237    CloneList,
238    CloneDestroy {
239        id_or_name: String,
240        /// Needed to destroy a protected clone.
241        #[serde(default)]
242        force: bool,
243        /// Also drop the name's reserved port and password.
244        #[serde(default)]
245        forget: bool,
246    },
247    /// Makes a running clone the newest generation of its template, or of `template`.
248    ClonePromote {
249        id_or_name: String,
250        #[serde(default, skip_serializing_if = "Option::is_none")]
251        template: Option<String>,
252    },
253    /// Starts a sleeping clone (a no-op for a running one); answered with `Response::Clone`.
254    CloneWake {
255        id_or_name: String,
256    },
257    /// What a clone changed against the template generation it came from; answered with
258    /// `Response::Diff`. A `parent_pid` limits it to a clone of that MCP session.
259    CloneDiff {
260        id_or_name: String,
261        /// A table (`name` or `schema.name`) to compare row by row.
262        #[serde(default, skip_serializing_if = "Option::is_none")]
263        table: Option<String>,
264        /// Rows shown per kind with `table`; default 20.
265        #[serde(default, skip_serializing_if = "Option::is_none")]
266        limit: Option<u32>,
267        #[serde(default, skip_serializing_if = "Option::is_none")]
268        parent_pid: Option<i32>,
269    },
270    /// `.env` restores the daemon could not finish.
271    PendingRestores,
272    PendingRestoreDone {
273        id: i64,
274    },
275    Gc,
276    Status,
277    /// Resource use of all clones against the limits.
278    Usage,
279    /// Re-read `config.toml` and the environment; later requests use the new settings.
280    ConfigReload,
281    /// Past events matching `filter`, oldest first. With `follow` the daemon keeps the connection
282    /// open after that list and writes one `Response::Event` line per new event.
283    Events {
284        #[serde(default)]
285        filter: EventFilter,
286        #[serde(default)]
287        follow: bool,
288    },
289    Shutdown,
290}
291
292/// One entry of the event log. `detail` never holds a password or a URL with one.
293#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
294pub struct EventRow {
295    pub id: i64,
296    pub ts: i64,
297    pub kind: String,
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub clone: Option<String>,
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub template: Option<String>,
302    pub detail: serde_json::Value,
303}
304
305/// Which events to list; an unset field matches everything.
306#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
307pub struct EventFilter {
308    /// A clone's name or id.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub clone: Option<String>,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub template: Option<String>,
313    /// An exact kind (`clone.created`) or a group (`clone` matches every `clone.*`).
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub kind: Option<String>,
316    /// Unix seconds; only events at or after it.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub since: Option<i64>,
319    /// The newest `limit` matches (still listed oldest first).
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub limit: Option<u32>,
322}
323
324#[derive(Serialize, Deserialize, Clone, Debug)]
325pub struct TemplateInfo {
326    pub name: String,
327    pub pg_major: u32,
328    pub source: TemplateSource,
329    pub database: String,
330    pub size_bytes: u64,
331    pub built_at: i64,
332    pub age_secs: u64,
333    pub warnings: Vec<String>,
334    #[serde(default)]
335    pub current_gen: u32,
336    /// How many generations the template has on disk.
337    #[serde(default, rename = "gen_count")]
338    pub generations: u32,
339    #[serde(default)]
340    pub paused: bool,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub last_error: Option<String>,
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub every_secs: Option<u64>,
345    #[serde(default)]
346    pub every_mode: RefreshMode,
347    /// The migrate command, passwords masked.
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub migrate: Option<String>,
350    #[serde(default)]
351    pub auto_migrate: bool,
352    #[serde(default)]
353    pub on_stale: OnStale,
354    #[serde(default)]
355    pub keep: u32,
356    /// The masking file, when the template has one.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub mask: Option<PathBuf>,
359    /// A branch template: its base template.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub base: Option<String>,
362    /// A branch template: its git branch.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub branch: Option<String>,
365    /// A base template: the branch its recorded checkout follows.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub base_branch: Option<String>,
368}
369
370#[derive(Serialize, Deserialize, Clone, Debug)]
371pub struct CloneInfo {
372    pub id: String,
373    pub name: Option<String>,
374    pub template: String,
375    pub url: String,
376    pub port: u16,
377    pub socket_dir: PathBuf,
378    pub template_age_secs: u64,
379    pub created_at: i64,
380    pub expires_at: i64,
381    pub durable: bool,
382    pub parent_pid: Option<i32>,
383    pub rss_bytes: Option<u64>,
384    pub own_disk_bytes: Option<u64>,
385    /// The template generation this clone was made from.
386    #[serde(default, rename = "gen")]
387    pub generation: u32,
388    /// The template was older than its max age when this clone was made.
389    #[serde(default)]
390    pub stale: bool,
391    /// A background refresh of the template was running (or was started) when this clone was made.
392    #[serde(default)]
393    pub refreshing: bool,
394    /// A named clone that was already running was returned instead of a new one.
395    #[serde(default)]
396    pub existing: bool,
397    /// Never expires and is not destroyed with a parent; `destroy` needs `--force`.
398    #[serde(default)]
399    pub protected: bool,
400    #[serde(default)]
401    pub warnings: Vec<String>,
402    /// Wall time of the create request.
403    #[serde(default)]
404    pub duration_ms: u64,
405    /// Time the create spent queued for room under the limits.
406    #[serde(default)]
407    pub waited_ms: u64,
408    /// The clone's own disk use is past `clone_disk_warn`.
409    #[serde(default)]
410    pub disk_warn: bool,
411    /// Restarted after an unclean shutdown with fsync off: recent writes may be lost or corrupt.
412    #[serde(default)]
413    pub suspect: bool,
414    /// The URL straight to the clone's own port; `url` is the router URL while the router is on.
415    #[serde(default)]
416    pub direct_url: String,
417    /// `ready`, `sleeping`, `starting` or `stopping`.
418    #[serde(default)]
419    pub status: String,
420    /// Made from a branch template: its git branch.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub branch: Option<String>,
423    /// The branch template was rebuilt before this clone was made.
424    #[serde(default)]
425    pub rebuilt: bool,
426}
427
428/// What a clone changed against the template generation it was made from.
429#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
430pub struct DiffReport {
431    pub clone: String,
432    pub template: String,
433    #[serde(rename = "gen")]
434    pub generation: u32,
435    pub schema: Vec<SchemaChange>,
436    /// Every table of either side; `public.` is left off names.
437    pub tables: Vec<TableCount>,
438    /// Only with `--table`.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub rows: Option<RowDiff>,
441}
442
443#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
444pub struct SchemaChange {
445    /// table, column, index, constraint, view, function, sequence or extension.
446    pub kind: String,
447    pub name: String,
448    /// added, removed or changed.
449    pub change: String,
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub before: Option<String>,
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub after: Option<String>,
454}
455
456#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
457pub struct TableCount {
458    pub table: String,
459    /// None: the table is not there on that side.
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub before: Option<Count>,
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub after: Option<Count>,
464}
465
466#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
467pub struct Count {
468    pub n: u64,
469    /// `n` is the planner's estimate: the exact count timed out.
470    #[serde(default)]
471    pub estimate: bool,
472}
473
474/// Row-level changes of one table. `pk` is the key value, or an array for a composite key.
475#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
476pub struct RowDiff {
477    pub table: String,
478    pub inserted: Vec<serde_json::Value>,
479    pub deleted: Vec<serde_json::Value>,
480    pub updated: Vec<RowChange>,
481    pub inserted_total: u64,
482    pub deleted_total: u64,
483    pub updated_total: u64,
484}
485
486#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
487pub struct RowChange {
488    pub pk: serde_json::Value,
489    /// Columns whose values differ.
490    pub columns: Vec<String>,
491}
492
493#[derive(Serialize, Deserialize, Clone, Debug, Default)]
494pub struct GcReport {
495    pub adopted: Vec<String>,
496    pub destroyed: Vec<String>,
497    pub orphan_folders_removed: Vec<PathBuf>,
498    pub records_dropped: Vec<String>,
499    /// Entries gc could not clean up; they are retried on the next gc.
500    #[serde(default)]
501    pub failed: Vec<GcFailure>,
502    /// Things the caller should know, such as a restarted clone that is suspect.
503    #[serde(default)]
504    pub warnings: Vec<String>,
505}
506
507#[derive(Serialize, Deserialize, Clone, Debug)]
508pub struct GcFailure {
509    /// Clone id or folder path.
510    pub item: String,
511    pub error: String,
512}
513
514#[derive(Serialize, Deserialize, Clone, Debug)]
515pub struct DaemonStatus {
516    pub pid: u32,
517    pub home: PathBuf,
518    pub clones: usize,
519    pub templates: usize,
520    pub uptime_secs: u64,
521    /// Crate version of the daemon binary.
522    #[serde(default)]
523    pub version: String,
524    /// Started by the launchd login service.
525    #[serde(default)]
526    pub service: bool,
527    /// The router's state; none when `router = off`.
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    pub router: Option<RouterStatus>,
530}
531
532#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
533pub struct RouterStatus {
534    pub listening: bool,
535    /// The address the router binds (`router_listen`); empty before the first bind attempt.
536    #[serde(default)]
537    pub listen: String,
538    pub port: u16,
539    /// Why the router is not listening (a taken port, say); it retries every minute.
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub error: Option<String>,
542}
543
544#[derive(Serialize, Deserialize, Clone, Debug)]
545#[serde(tag = "type", rename_all = "snake_case")]
546pub enum Response {
547    TemplateBuilt(TemplateInfo),
548    Templates {
549        templates: Vec<TemplateInfo>,
550    },
551    Generations {
552        template: String,
553        generations: Vec<GenerationInfo>,
554        /// Automatic builds are stopped (after a rollback).
555        #[serde(default)]
556        paused: bool,
557    },
558    CloneCreated(CloneInfo),
559    /// One clone, e.g. after `CloneWake`.
560    Clone(CloneInfo),
561    Clones {
562        clones: Vec<CloneInfo>,
563    },
564    Destroyed {
565        id: String,
566    },
567    PendingRestores {
568        items: Vec<PendingRestore>,
569    },
570    Gc(GcReport),
571    Status(DaemonStatus),
572    Usage(crate::budget::Usage),
573    Events {
574        events: Vec<EventRow>,
575    },
576    /// One new event on a following `Request::Events` connection.
577    Event(EventRow),
578    Diff(DiffReport),
579    Ok,
580}
581
582#[derive(Serialize, Deserialize, Clone, Debug)]
583pub struct RequestEnvelope {
584    pub v: u32,
585    pub request: Request,
586}
587
588#[derive(Serialize, Deserialize, Clone, Debug)]
589pub struct ResponseEnvelope {
590    pub v: u32,
591    pub result: std::result::Result<Response, MitosisError>,
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::error::ErrorCode;
598
599    #[test]
600    fn gc_is_tagged_only() {
601        assert_eq!(
602            serde_json::to_string(&Request::Gc).unwrap(),
603            r#"{"type":"gc"}"#
604        );
605    }
606
607    #[test]
608    fn request_envelope_round_trip() {
609        let env = RequestEnvelope {
610            v: PROTOCOL_VERSION,
611            request: Request::CloneCreate(CloneCreateArgs {
612                template: "main".into(),
613                name: Some("feat".into()),
614                ttl_secs: 7200,
615                durable: false,
616                parent_pid: Some(42),
617                fresh: false,
618                protect: false,
619                env: None,
620                wait_secs: None,
621                branch: None,
622            }),
623        };
624        let line = serde_json::to_string(&env).unwrap();
625        assert!(!line.contains('\n'));
626        let back: RequestEnvelope = serde_json::from_str(&line).unwrap();
627        assert_eq!(back.v, PROTOCOL_VERSION);
628        assert_eq!(PROTOCOL_VERSION, 3);
629        match back.request {
630            Request::CloneCreate(a) => {
631                assert_eq!(a.template, "main");
632                assert_eq!(a.name.as_deref(), Some("feat"));
633                assert_eq!(a.ttl_secs, 7200);
634                assert_eq!(a.parent_pid, Some(42));
635            }
636            other => panic!("wrong variant: {other:?}"),
637        }
638    }
639
640    #[test]
641    fn err_response_round_trip() {
642        let env = ResponseEnvelope {
643            v: PROTOCOL_VERSION,
644            result: Err(
645                MitosisError::new(ErrorCode::CapReached, "too many").with_hint("destroy one")
646            ),
647        };
648        let line = serde_json::to_string(&env).unwrap();
649        let back: ResponseEnvelope = serde_json::from_str(&line).unwrap();
650        let e = back.result.unwrap_err();
651        assert_eq!(e.code, ErrorCode::CapReached);
652        assert_eq!(e.hint.as_deref(), Some("destroy one"));
653    }
654
655    fn template_info() -> TemplateInfo {
656        TemplateInfo {
657            name: "main".into(),
658            pg_major: 16,
659            source: TemplateSource::Url {
660                url: "postgres://x".into(),
661            },
662            database: "app".into(),
663            size_bytes: 1024,
664            built_at: 1_700_000_000,
665            age_secs: 5,
666            warnings: vec!["w".into()],
667            current_gen: 2,
668            generations: 2,
669            paused: true,
670            last_error: Some("boom".into()),
671            every_secs: Some(86_400),
672            every_mode: RefreshMode::Layered,
673            migrate: Some("make migrate".into()),
674            auto_migrate: true,
675            on_stale: OnStale::Refresh,
676            keep: 3,
677            mask: None,
678            base: None,
679            branch: None,
680            base_branch: None,
681        }
682    }
683
684    fn clone_info() -> CloneInfo {
685        CloneInfo {
686            id: "abc123".into(),
687            name: Some("feat".into()),
688            template: "main".into(),
689            url: "postgres://localhost:5500/app".into(),
690            port: 5500,
691            socket_dir: "/tmp/s".into(),
692            template_age_secs: 5,
693            created_at: 1,
694            expires_at: 2,
695            durable: false,
696            parent_pid: Some(7),
697            rss_bytes: None,
698            own_disk_bytes: Some(9),
699            generation: 2,
700            stale: true,
701            refreshing: true,
702            existing: false,
703            protected: false,
704            warnings: Vec::new(),
705            duration_ms: 0,
706            waited_ms: 0,
707            disk_warn: false,
708            suspect: false,
709            direct_url: "postgres://localhost:5500/app".into(),
710            status: "ready".into(),
711            branch: None,
712            rebuilt: false,
713        }
714    }
715
716    fn round_trip(r: Response) {
717        let env = ResponseEnvelope {
718            v: PROTOCOL_VERSION,
719            result: Ok(r),
720        };
721        let line = serde_json::to_string(&env).unwrap();
722        let back: ResponseEnvelope = serde_json::from_str(&line).unwrap();
723        assert_eq!(
724            serde_json::to_value(&env).unwrap(),
725            serde_json::to_value(&back).unwrap()
726        );
727    }
728
729    #[test]
730    fn list_and_newtype_responses_round_trip() {
731        round_trip(Response::Templates {
732            templates: vec![template_info()],
733        });
734        round_trip(Response::Clones {
735            clones: vec![clone_info()],
736        });
737        round_trip(Response::TemplateBuilt(template_info()));
738        round_trip(Response::CloneCreated(clone_info()));
739        round_trip(Response::Generations {
740            template: "main".into(),
741            generations: vec![GenerationInfo {
742                gen: 1,
743                kind: GenKind::Promoted,
744                built_at: 1,
745                pg_major: 16,
746                size_bytes: 2,
747                current: true,
748                from_clone: Some("feat".into()),
749                from_gen: Some(3),
750            }],
751            paused: true,
752        });
753    }
754
755    #[test]
756    fn v01_shapes_still_parse() {
757        let t: TemplateInfo = serde_json::from_str(
758            r#"{"name":"main","pg_major":16,"source":{"kind":"url","url":"postgres://h/db"},
759                "database":"db","size_bytes":1,"built_at":1,"age_secs":1,"warnings":[]}"#,
760        )
761        .unwrap();
762        assert_eq!((t.current_gen, t.paused, t.last_error), (0, false, None));
763        let r: Request =
764            serde_json::from_str(r#"{"type":"template_rollback","name":"main"}"#).unwrap();
765        assert!(matches!(r, Request::TemplateRollback { to: None, .. }));
766        assert_eq!(
767            serde_json::to_string(&GenKind::Upgraded).unwrap(),
768            r#""upgraded""#
769        );
770    }
771
772    #[test]
773    fn seed_cwd_is_optional() {
774        let s: TemplateSource =
775            serde_json::from_str(r#"{"kind":"seed","command":"make seed"}"#).unwrap();
776        assert_eq!(
777            s,
778            TemplateSource::Seed {
779                command: "make seed".into(),
780                cwd: None,
781                path: None
782            }
783        );
784        let s = TemplateSource::Seed {
785            command: "x".into(),
786            cwd: Some("/w".into()),
787            path: Some("/usr/bin:/bin".into()),
788        };
789        assert_eq!(
790            serde_json::from_str::<TemplateSource>(&serde_json::to_string(&s).unwrap()).unwrap(),
791            s
792        );
793    }
794
795    #[test]
796    fn refresh_path_is_optional() {
797        let r: Request =
798            serde_json::from_str(r#"{"type":"template_refresh","name":"main"}"#).unwrap();
799        assert!(matches!(
800            r,
801            Request::TemplateRefresh {
802                path: None,
803                layered: false,
804                ..
805            }
806        ));
807        let r: GcReport = serde_json::from_str(
808            r#"{"adopted":[],"destroyed":[],"orphan_folders_removed":[],"records_dropped":[]}"#,
809        )
810        .unwrap();
811        assert!(r.failed.is_empty());
812    }
813
814    #[test]
815    fn template_settings_are_flat_and_optional() {
816        let r: Request = serde_json::from_str(
817            r#"{"type":"template_init","name":"main","source":{"kind":"seed","command":"x"},
818                "database":null,"migrations":null,"max_age_secs":null,"pg_major":null}"#,
819        )
820        .unwrap();
821        let Request::TemplateInit(a) = r else {
822            panic!("wrong variant")
823        };
824        assert_eq!(a.settings, TemplateSettings::default());
825
826        let set = Request::TemplateSet(TemplateSetArgs {
827            name: "main".into(),
828            max_age_secs: Some(60),
829            settings: TemplateSettings {
830                keep: Some(2),
831                every_secs: Some(3600),
832                every_mode: Some(RefreshMode::Layered),
833                migrate: Some(MigrateCmd {
834                    command: "make migrate".into(),
835                    cwd: "/w".into(),
836                    path: None,
837                }),
838                auto_migrate: Some(true),
839                on_stale: Some(OnStale::Refresh),
840                ..Default::default()
841            },
842            resume: true,
843        });
844        let line = serde_json::to_string(&set).unwrap();
845        assert!(line.contains(r#""keep":2"#), "{line}");
846        let Request::TemplateSet(back) = serde_json::from_str(&line).unwrap() else {
847            panic!("wrong variant")
848        };
849        assert_eq!(back.settings.keep, Some(2));
850        assert_eq!(back.settings.every_mode, Some(RefreshMode::Layered));
851        assert_eq!(back.settings.migrate.unwrap().command, "make migrate");
852        assert!(back.resume && !back.settings.every_off);
853        let r: Response =
854            serde_json::from_str(r#"{"type":"generations","template":"main","generations":[]}"#)
855                .unwrap();
856        assert!(matches!(r, Response::Generations { paused: false, .. }));
857    }
858
859    #[test]
860    fn events_round_trip_and_defaults() {
861        let row = EventRow {
862            id: 7,
863            ts: 1_700_000_000,
864            kind: "clone.created".into(),
865            clone: Some("feat".into()),
866            template: None,
867            detail: serde_json::json!({ "id": "abc123" }),
868        };
869        round_trip(Response::Events {
870            events: vec![row.clone()],
871        });
872        round_trip(Response::Event(row));
873        // A request that names no filter and no follow still parses.
874        let r: Request = serde_json::from_str(r#"{"type":"events"}"#).unwrap();
875        let Request::Events { filter, follow } = r else {
876            panic!("wrong variant")
877        };
878        assert_eq!((filter, follow), (EventFilter::default(), false));
879        assert_eq!(
880            serde_json::to_string(&EventFilter::default()).unwrap(),
881            "{}"
882        );
883    }
884
885    #[test]
886    fn ok_response_round_trip() {
887        let env = ResponseEnvelope {
888            v: PROTOCOL_VERSION,
889            result: Ok(Response::Ok),
890        };
891        let line = serde_json::to_string(&env).unwrap();
892        let back: ResponseEnvelope = serde_json::from_str(&line).unwrap();
893        assert!(matches!(back.result, Ok(Response::Ok)));
894    }
895}