Skip to main content

git_policy_cli/
transaction_journal_encode.rs

1//! What:
2//!  The exact encoding of every schema-version-2 journal record:
3//!  compact JSON in the
4//!       incumbent's field order,
5//!  one record per line.
6//! Why:
7//!  `writeJournalRecord` writes `JSON.stringify(record)` and a newline;
8//!  the native wrapper
9//!      writes the same bytes for the same values so either wrapper can recover the other's
10//!      transactions.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! `${JSON.stringify(record)}\n`
15//! ```
16
17/// The ordered writer and array encoding.
18/// The event form of a pathname and the base64 of its bytes.
19use super::event_path::{EventPath, base64_standard};
20/// The digest of an index snapshot.
21use super::content_digest::ContentDigest;
22use super::json_record::{ObjectWriter, string_array};
23/// The record types and their wire names.
24use super::transaction_journal::{
25    AddedPath, Base, Conclusion, FileIdentity, IndexLockRecord, JOURNAL_SCHEMA_VERSION,
26    LandingOperation, LandingRecord, LockIdentity, PreparedRecord, PreparingRecord, RefFormat,
27    SymbolicHead, TransactionMode,
28};
29
30/// What:
31///  Encode a base as its tagged object.
32/// Why:
33///   Shared by `preparing.json` and `landing-<n>.json`.
34///
35/// In TS you'd write (pseudocode):
36/// ```ts
37/// JSON.stringify(base)
38/// ```
39fn encode_base(base: &Base) -> String {
40    let mut writer: ObjectWriter = ObjectWriter::new();
41    match base {
42        Base::Unborn => {
43            writer.string("kind", "unborn");
44        }
45        Base::Commit(oid) => {
46            writer.string("kind", "commit").string("oid", oid.as_str());
47        }
48    }
49    return writer.finish();
50}
51
52/// What:
53///  Encode a file identity.
54/// Why:
55///   Shared by landing artifacts and locks.
56///
57/// In TS you'd write (pseudocode):
58/// ```ts
59/// JSON.stringify({ device, inode })
60/// ```
61fn encode_identity(identity: &FileIdentity) -> String {
62    let mut writer: ObjectWriter = ObjectWriter::new();
63    writer
64        .string("device", identity.device.as_str())
65        .string("inode", identity.inode.as_str());
66    return writer.finish();
67}
68
69/// What:
70///  Encode the content digest of an index snapshot.
71/// Why:
72///   Recovery compares the snapshot with it before installing the snapshot as the real index.
73///
74/// In TS you'd write (pseudocode):
75/// ```ts
76/// JSON.stringify({ length, fnv1a64 })
77/// ```
78fn encode_digest(digest: &ContentDigest) -> String {
79    let mut writer: ObjectWriter = ObjectWriter::new();
80    writer
81        .integer("length", digest.length as i64)
82        .string("fnv1a64", digest.hash_text().as_str());
83    return writer.finish();
84}
85
86/// What:
87///  Encode a lock identity.
88/// Why:
89///   Shared by `index-lock-<n>.json` and `landing-<n>.json`.
90///
91/// In TS you'd write (pseudocode):
92/// ```ts
93/// JSON.stringify({ device, inode, fsId })
94/// ```
95fn encode_lock(lock: &LockIdentity) -> String {
96    let mut writer: ObjectWriter = ObjectWriter::new();
97    writer
98        .string("device", lock.file.device.as_str())
99        .string("inode", lock.file.inode.as_str())
100        .string("fsId", lock.fs_id.as_str());
101    return writer.finish();
102}
103
104/// What:
105///  Append a list of paths as `key`, the readable text of each, and,
106///  when any path is not UTF-8, as `bytes_key`, the base64 of the exact bytes of every path.
107/// Why:
108///   The incumbent wrote only text, so a list whose paths are all UTF-8 stays byte for byte what it wrote;
109///  a name that is not UTF-8 cannot be a JSON string, and an array has no absent entry, so the byte list names every
110///  path once it is needed at all (the rule of `changedPathBytes` in events).
111///
112/// In TS you'd write (pseudocode):
113/// ```ts
114/// record[key] = paths.map(p => p.text); if (paths.some(p => p.exact)) record[bytesKey] = paths.map(p => base64(p.bytes));
115/// ```
116fn push_path_list(writer: &mut ObjectWriter, key: &str, bytes_key: &str, paths: &[Vec<u8>]) {
117    let event_paths: Vec<EventPath> = paths.iter().map(|path| { return EventPath::from_git_bytes(path.as_slice()); }).collect();
118    let texts: Vec<String> = event_paths.iter().map(|path| { return String::from(path.text()); }).collect();
119    writer.raw(key, string_array(texts.as_slice()).as_str());
120    if event_paths.iter().any(|path| { return path.exact().is_some(); }) {
121        let encoded: Vec<String> = event_paths.iter().map(|path| { return base64_standard(path.bytes()); }).collect();
122        writer.raw(bytes_key, string_array(encoded.as_slice()).as_str());
123    }
124}
125
126/// What:
127///  Encode a list of added-path records.
128/// Why:
129///   Shared by `prepared.json` and `landing-<n>.json`.
130///
131/// In TS you'd write (pseudocode):
132/// ```ts
133/// JSON.stringify(records)
134/// ```
135fn encode_added_paths(records: &[AddedPath]) -> String {
136    let mut text: String = String::from("[");
137    for (index, record) in records.iter().enumerate() {
138        if index > 0 {
139            text.push(',');
140        }
141        let mut writer: ObjectWriter = ObjectWriter::new();
142        let path: EventPath = EventPath::from_git_bytes(record.path.as_slice());
143        writer.string("path", path.text());
144        if let Some(exact) = path.exact() {
145            writer.string("pathBytes", base64_standard(exact).as_str());
146        }
147        writer
148            .string("gitMode", record.git_mode.as_str())
149            .string("originalOid", record.original_oid.as_str())
150            .string("intendedOid", record.intended_oid.as_str());
151        text.push_str(writer.finish().as_str());
152    }
153    text.push(']');
154    return text;
155}
156
157/// What:
158///  The common head of every record:
159///  schema version and state discriminator.
160/// Why:
161///   Every record starts with the same two fields.
162///
163/// In TS you'd write (pseudocode):
164/// ```ts
165/// { schemaVersion: 2, state }
166/// ```
167fn record_head(state: &str) -> ObjectWriter {
168    let mut writer: ObjectWriter = ObjectWriter::new();
169    writer
170        .integer("schemaVersion", JOURNAL_SCHEMA_VERSION)
171        .string("state", state);
172    return writer;
173}
174
175/// What:
176///  The wire spelling of a mode.
177/// Why:
178///   Shared by encoding and its tests.
179///
180/// In TS you'd write (pseudocode):
181/// ```ts
182/// mode
183/// ```
184pub fn mode_name(mode: TransactionMode) -> &'static str {
185    match mode {
186        TransactionMode::ExplicitPath => return "explicit-path",
187        TransactionMode::Index => return "index",
188    }
189}
190
191/// What:
192///  The wire spelling of a conclusion kind.
193/// Why:
194///   Shared by encoding and its tests.
195///
196/// In TS you'd write (pseudocode):
197/// ```ts
198/// conclusion
199/// ```
200pub fn conclusion_name(conclusion: Conclusion) -> &'static str {
201    match conclusion {
202        Conclusion::None => return "none",
203        Conclusion::Amend => return "amend",
204        Conclusion::Merge => return "merge",
205        Conclusion::CherryPick => return "cherry-pick",
206        Conclusion::Revert => return "revert",
207    }
208}
209
210/// What:
211///  The wire spelling of a ref format.
212/// Why:
213///   Shared by encoding and its tests.
214///
215/// In TS you'd write (pseudocode):
216/// ```ts
217/// refFormat
218/// ```
219pub fn ref_format_name(format: RefFormat) -> &'static str {
220    match format {
221        RefFormat::Files => return "files",
222        RefFormat::Reftable => return "reftable",
223    }
224}
225
226/// What:
227///  Encode `preparing.json` with its trailing newline.
228/// Why:
229///   `writeJournalRecord` writes `JSON.stringify(record)` and a newline.
230///
231/// In TS you'd write (pseudocode):
232/// ```ts
233/// `${JSON.stringify(preparing)}\n`
234/// ```
235pub fn encode_preparing(record: &PreparingRecord) -> String {
236    let mut writer: ObjectWriter = record_head("preparing");
237    writer
238        .string("transactionId", record.transaction_id.as_str())
239        .string("mode", mode_name(record.mode))
240        .raw("base", encode_base(&record.base).as_str());
241    let head: String = match &record.symbolic_head {
242        SymbolicHead::Branch(name) => {
243            let mut nested: ObjectWriter = ObjectWriter::new();
244            nested.string("kind", "branch").string("ref", name.as_str());
245            nested.finish()
246        }
247        SymbolicHead::Detached => String::from("{\"kind\":\"detached\"}"),
248    };
249    writer
250        .raw("symbolicHead", head.as_str())
251        .string("targetRef", record.target_ref.as_str())
252        .string("conclusion", conclusion_name(record.conclusion))
253        .string("repositoryRoot", record.repository_root.as_str())
254        .string("gitDir", record.git_dir.as_str())
255        .string("commonDir", record.common_dir.as_str())
256        .string("realIndexPath", record.real_index_path.as_str())
257        .string("objectDirectory", record.object_directory.as_str())
258        .string("refFormat", ref_format_name(record.ref_format))
259        .string("emptyTreeOid", record.empty_tree_oid.as_str())
260        .string("shadowPath", record.shadow_path.as_str());
261    push_path_list(&mut writer, "selectedPathspecs", "selectedPathspecBytes", record.selected_pathspecs.as_slice());
262    writer.string("invokedAt", record.invoked_at.as_str());
263    return format!("{}\n", writer.finish());
264}
265
266/// What:
267///  Encode `prepared.json` with its trailing newline.
268/// Why:
269///   See `encode_preparing`.
270///
271/// In TS you'd write (pseudocode):
272/// ```ts
273/// `${JSON.stringify(prepared)}\n`
274/// ```
275pub fn encode_prepared(record: &PreparedRecord) -> String {
276    let mut writer: ObjectWriter = record_head("prepared");
277    writer
278        .string("shadowPath", record.shadow_path.as_str())
279        .string("preparedOid", record.prepared_oid.as_str())
280        .boolean("signed", record.signed)
281        .string("intendedTreeOid", record.intended_tree_oid.as_str());
282    push_path_list(&mut writer, "committedPaths", "committedPathBytes", record.committed_paths.as_slice());
283    writer
284        .raw(
285            "addedPaths",
286            encode_added_paths(record.added_paths.as_slice()).as_str(),
287        )
288        .raw(
289            "selectedWorktreePaths",
290            encode_added_paths(record.selected_worktree_paths.as_slice()).as_str(),
291        );
292    return format!("{}\n", writer.finish());
293}
294
295/// What:
296///  Encode `index-lock-<n>.json` with its trailing newline.
297/// Why:
298///   See `encode_preparing`.
299///
300/// In TS you'd write (pseudocode):
301/// ```ts
302/// `${JSON.stringify(indexLock)}\n`
303/// ```
304pub fn encode_index_lock(record: &IndexLockRecord) -> String {
305    let mut writer: ObjectWriter = record_head("index-locked");
306    writer
307        .integer("attempt", record.attempt)
308        .raw("lock", encode_lock(&record.lock).as_str());
309    return format!("{}\n", writer.finish());
310}
311
312/// What:
313///  Encode `landing-<n>.json` with its trailing newline;
314///  optional fields are omitted when
315///       absent,
316///  as `JSON.stringify` omits `undefined`.
317/// Why:
318///   See `encode_preparing`.
319///
320/// In TS you'd write (pseudocode):
321/// ```ts
322/// `${JSON.stringify(landing)}\n`
323/// ```
324pub fn encode_landing(record: &LandingRecord) -> String {
325    let mut writer: ObjectWriter = record_head("landing");
326    writer.integer("attempt", record.attempt).string(
327        "operation",
328        match record.operation {
329            LandingOperation::Commit => "commit",
330            LandingOperation::NormalizeOnly => "normalize-only",
331        },
332    );
333    writer.raw("expectedOld", encode_base(&record.expected_old).as_str());
334    if let Some(oid) = &record.new_oid {
335        writer.string("newOid", oid.as_str());
336    }
337    writer
338        .string("landedTreeOid", record.landed_tree_oid.as_str())
339        .raw(
340            "preLandingIndex",
341            encode_identity(&record.pre_landing_index).as_str(),
342        )
343        .raw("postIndex", encode_identity(&record.post_index).as_str())
344        .raw("lock", encode_lock(&record.lock).as_str());
345    if let Some(pack) = &record.pack_name {
346        writer.string("packName", pack.as_str());
347    }
348    writer
349        .raw(
350            "addedPaths",
351            encode_added_paths(record.added_paths.as_slice()).as_str(),
352        )
353        .raw(
354            "selectedWorktreePaths",
355            encode_added_paths(record.selected_worktree_paths.as_slice()).as_str(),
356        );
357    if let Some(digest) = &record.pre_landing_digest {
358        writer.raw("preLandingDigest", encode_digest(digest).as_str());
359    }
360    if let Some(digest) = &record.post_digest {
361        writer.raw("postDigest", encode_digest(digest).as_str());
362    }
363    return format!("{}\n", writer.finish());
364}
365
366/// What:
367///  Encode `ref-updated.json` with its trailing newline.
368/// Why:
369///   See `encode_preparing`.
370///
371/// In TS you'd write (pseudocode):
372/// ```ts
373/// `${JSON.stringify({ schemaVersion: 2, state: 'ref-updated', landedOid })}\n`
374/// ```
375pub fn encode_ref_updated(landed_oid: &str) -> String {
376    let mut writer: ObjectWriter = record_head("ref-updated");
377    writer.string("landedOid", landed_oid);
378    return format!("{}\n", writer.finish());
379}
380
381/// Encoding controls stay out of the release executable.
382#[cfg(test)]
383#[path = "transaction_journal_encode_tests.rs"]
384pub(crate) mod tests;