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