1use super::capture_store::{
22 CaptureError, capture_store_path, ensure_capture_store, read_next_capture_sequence,
23 read_store_file, read_worktree_id,
24};
25use super::diagnostic_log::{debug, warn};
27use super::json_record::{
29 MAX_SAFE_INTEGER, ObjectWriter, parse_object, safe_integer, string_array,
30};
31use super::private_storage::{sync_directory, write_private_file};
33use super::transaction_journal::JOURNAL_SCHEMA_VERSION;
35use super::transaction_registry::{
37 EntryKind, RegistryEntry, ensure_transaction_root, list_transaction_entries,
38};
39use serde_json::{Map, Value};
41use std::path::{Path, PathBuf};
43
44pub const CAPTURED_FILENAME: &str = "captured.json";
46
47pub const LANDED_DIRECTORY_NAME: &str = "landed";
49
50pub const LANDED_RECORD_SUFFIX: &str = ".json";
52
53pub const LANDED_SCHEMA_VERSION: i64 = 1;
55
56#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct CapturedRecord {
70 pub worktree_id: String,
72 pub sequence: i64,
75 pub next_sequence_before_base: i64,
77 pub worktree_paths: Vec<String>,
81}
82
83#[derive(Clone, Debug, Eq, PartialEq)]
94pub struct LandedCaptureRecord {
95 pub commit: String,
97 pub transaction_id: String,
99 pub worktree_id: String,
101 pub sequence: i64,
103 pub next_sequence_after_landing: i64,
105 pub worktree_paths: Vec<String>,
108}
109
110fn record_object(text: &str, name: &str) -> Result<Map<String, Value>, CaptureError> {
122 match parse_object(text) {
123 Some(map) => return Ok(map),
124 None => return Err(CaptureError(format!("{name} is not a JSON object."))),
125 }
126}
127
128fn malformed(name: &str, key: &str) -> CaptureError {
138 return CaptureError(format!("{name} has a malformed {key} field."));
139}
140
141fn text_field(map: &Map<String, Value>, key: &str, name: &str) -> Result<String, CaptureError> {
151 match map.get(key) {
152 Some(Value::String(text)) if !text.is_empty() => return Ok(text.clone()),
153 _ => return Err(malformed(name, key)),
154 }
155}
156
157fn positive_field(map: &Map<String, Value>, key: &str, name: &str) -> Result<i64, CaptureError> {
167 match map.get(key).and_then(safe_integer) {
168 Some(number) if number >= 1 => return Ok(number),
169 _ => return Err(malformed(name, key)),
170 }
171}
172
173fn paths_field(
183 map: &Map<String, Value>,
184 key: &str,
185 name: &str,
186) -> Result<Vec<String>, CaptureError> {
187 let Some(Value::Array(items)) = map.get(key) else {
188 return Err(malformed(name, key));
189 };
190 let mut paths: Vec<String> = Vec::with_capacity(items.len());
192 for item in items {
193 match item {
194 Value::String(path) if !path.is_empty() => paths.push(path.clone()),
195 _ => return Err(malformed(name, key)),
196 }
197 }
198 return Ok(paths);
199}
200
201pub fn parse_captured_record(text: &str) -> Result<CapturedRecord, CaptureError> {
211 let map: Map<String, Value> = record_object(text, CAPTURED_FILENAME)?;
212 if map.get("schemaVersion").and_then(safe_integer) != Some(JOURNAL_SCHEMA_VERSION)
213 || map.get("state").and_then(Value::as_str) != Some("captured")
214 {
215 return Err(CaptureError(format!(
216 "{CAPTURED_FILENAME} is not a schema-version-2 captured record."
217 )));
218 }
219 return Ok(CapturedRecord {
220 worktree_id: text_field(&map, "worktreeId", CAPTURED_FILENAME)?,
221 sequence: positive_field(&map, "sequence", CAPTURED_FILENAME)?,
222 next_sequence_before_base: positive_field(
223 &map,
224 "nextSequenceBeforeBase",
225 CAPTURED_FILENAME,
226 )?,
227 worktree_paths: paths_field(&map, "worktreePaths", CAPTURED_FILENAME)?,
228 });
229}
230
231pub fn encode_captured_record(record: &CapturedRecord) -> String {
242 let mut writer: ObjectWriter = ObjectWriter::new();
244 writer
245 .integer("schemaVersion", JOURNAL_SCHEMA_VERSION)
246 .string("state", "captured")
247 .string("worktreeId", record.worktree_id.as_str())
248 .integer("sequence", record.sequence)
249 .integer("nextSequenceBeforeBase", record.next_sequence_before_base)
250 .raw(
251 "worktreePaths",
252 string_array(record.worktree_paths.as_slice()).as_str(),
253 );
254 return format!("{}\n", writer.finish());
255}
256
257pub fn read_captured_record(directory: &Path) -> Result<Option<CapturedRecord>, CaptureError> {
268 match read_store_file(directory.join(CAPTURED_FILENAME).as_path())? {
269 Some(text) => return Ok(Some(parse_captured_record(text.as_str())?)),
270 None => return Ok(None),
271 }
272}
273
274pub fn landed_record_directory(git_dir: &Path) -> PathBuf {
284 return capture_store_path(git_dir).join(LANDED_DIRECTORY_NAME);
285}
286
287pub fn parse_landed_capture_record(
297 text: &str,
298 name: &str,
299) -> Result<LandedCaptureRecord, CaptureError> {
300 let map: Map<String, Value> = record_object(text, name)?;
301 if map.get("schemaVersion").and_then(safe_integer) != Some(LANDED_SCHEMA_VERSION) {
302 return Err(CaptureError(format!(
303 "{name} is not a schema-version-1 landed-capture record."
304 )));
305 }
306 return Ok(LandedCaptureRecord {
307 commit: text_field(&map, "commit", name)?,
308 transaction_id: text_field(&map, "transactionId", name)?,
309 worktree_id: text_field(&map, "worktreeId", name)?,
310 sequence: positive_field(&map, "sequence", name)?,
311 next_sequence_after_landing: positive_field(&map, "nextSequenceAfterLanding", name)?,
312 worktree_paths: paths_field(&map, "worktreePaths", name)?,
313 });
314}
315
316pub fn encode_landed_capture_record(record: &LandedCaptureRecord) -> String {
327 let mut writer: ObjectWriter = ObjectWriter::new();
329 writer
330 .integer("schemaVersion", LANDED_SCHEMA_VERSION)
331 .string("commit", record.commit.as_str())
332 .string("transactionId", record.transaction_id.as_str())
333 .string("worktreeId", record.worktree_id.as_str())
334 .integer("sequence", record.sequence)
335 .integer(
336 "nextSequenceAfterLanding",
337 record.next_sequence_after_landing,
338 )
339 .raw(
340 "worktreePaths",
341 string_array(record.worktree_paths.as_slice()).as_str(),
342 );
343 return format!("{}\n", writer.finish());
344}
345
346pub fn record_landed_capture(
361 git_dir: &Path,
362 transaction_directory: &Path,
363 transaction_id: &str,
364 landed_oid: &str,
365) -> Result<bool, CaptureError> {
366 let Some(captured) = read_captured_record(transaction_directory)? else {
367 debug(
368 "recordLandedCapture",
369 format!(
370 "transaction {transaction_id} captured without capture order; {landed_oid} gets no landed record"
371 )
372 .as_str(),
373 );
374 return Ok(false);
375 };
376 ensure_capture_store(git_dir)?;
377 let directory: PathBuf = landed_record_directory(git_dir);
378 if let Err(error) = ensure_transaction_root(directory.as_path()) {
379 return Err(CaptureError(error.0));
380 }
381 let record: LandedCaptureRecord = LandedCaptureRecord {
382 commit: String::from(landed_oid),
383 transaction_id: String::from(transaction_id),
384 worktree_id: captured.worktree_id,
385 sequence: captured.sequence,
386 next_sequence_after_landing: read_next_capture_sequence(git_dir)?,
387 worktree_paths: captured.worktree_paths,
388 };
389 let path: PathBuf = directory.join(format!("{landed_oid}{LANDED_RECORD_SUFFIX}"));
390 match write_private_file(
391 path.as_path(),
392 encode_landed_capture_record(&record).as_bytes(),
393 ) {
394 Ok(()) => {
395 if let Err(error) = sync_directory(directory.as_path()) {
396 return Err(CaptureError(format!(
397 "syncing {} failed: {error}",
398 directory.display()
399 )));
400 }
401 debug(
402 "recordLandedCapture",
403 format!(
404 "recorded capture {} for landed {landed_oid}",
405 record.sequence
406 )
407 .as_str(),
408 );
409 }
410 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
411 debug(
412 "recordLandedCapture",
413 format!("landed record of {landed_oid} already exists: {error}").as_str(),
414 );
415 }
416 Err(error) => {
417 return Err(CaptureError(format!(
418 "writing {} failed: {error}",
419 path.display()
420 )));
421 }
422 }
423 return Ok(true);
424}
425
426pub fn record_landed_capture_or_warn(
441 git_dir: &Path,
442 transaction_directory: &Path,
443 transaction_id: &str,
444 landed_oid: &str,
445) -> bool {
446 match record_landed_capture(git_dir, transaction_directory, transaction_id, landed_oid) {
447 Ok(recorded) => return recorded,
448 Err(error) => {
449 warn(
450 "recordLandedCaptureOrWarn",
451 format!(
452 "could not record the capture of landed {landed_oid}; later replays over it use subsumption: {error}"
453 )
454 .as_str(),
455 );
456 return false;
457 }
458 }
459}
460
461pub fn list_landed_record_names(git_dir: &Path) -> Result<Vec<String>, CaptureError> {
472 let directory: PathBuf = landed_record_directory(git_dir);
473 let listing: std::fs::ReadDir = match std::fs::read_dir(&directory) {
474 Ok(found) => found,
475 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
476 debug(
477 "listLandedRecordNames",
478 format!("no landed-capture records in {}", git_dir.display()).as_str(),
479 );
480 return Ok(Vec::new());
481 }
482 Err(error) => {
483 return Err(CaptureError(format!(
484 "listing {} failed: {error}",
485 directory.display()
486 )));
487 }
488 };
489 let mut names: Vec<String> = Vec::new();
491 for item in listing {
492 match item {
493 Ok(entry) => names.push(entry.file_name().to_string_lossy().into_owned()),
494 Err(error) => {
495 return Err(CaptureError(format!(
496 "listing {} failed: {error}",
497 directory.display()
498 )));
499 }
500 }
501 }
502 return Ok(names);
503}
504
505fn pinning_bound(
519 registry_root: &Path,
520 worktree_id: Option<&str>,
521) -> Result<Option<i64>, CaptureError> {
522 let entries: Vec<RegistryEntry> = match list_transaction_entries(registry_root) {
523 Ok(found) => found,
524 Err(error) => return Err(CaptureError(error.0)),
525 };
526 let mut bound: i64 = i64::MAX;
528 for entry in entries {
529 if entry.kind != EntryKind::Transaction {
530 continue;
531 }
532 let Some(captured) = read_captured_record(entry.path.as_path())? else {
533 return Ok(None);
534 };
535 if Some(captured.worktree_id.as_str()) == worktree_id {
536 bound = bound.min(captured.next_sequence_before_base);
537 }
538 }
539 return Ok(Some(bound));
540}
541
542fn prunable(directory: &Path, name: &str, worktree_id: Option<&str>, bound: i64) -> bool {
558 if !name.ends_with(LANDED_RECORD_SUFFIX) {
559 return true;
560 }
561 let path: PathBuf = directory.join(name);
562 let name_text: String = path.display().to_string();
563 let parsed: Result<LandedCaptureRecord, CaptureError> = match read_store_file(path.as_path()) {
564 Ok(Some(text)) => parse_landed_capture_record(text.as_str(), name_text.as_str()),
565 Ok(None) => Err(CaptureError(format!("{name_text} vanished"))),
566 Err(error) => Err(error),
567 };
568 match parsed {
569 Ok(record) => {
570 return Some(record.worktree_id.as_str()) != worktree_id
571 || record.next_sequence_after_landing < bound;
572 }
573 Err(error) => {
574 warn(
575 "pruneLandedCaptures",
576 format!("removing unreadable landed-capture record {name_text}: {error}").as_str(),
577 );
578 return true;
579 }
580 }
581}
582
583pub fn prune_landed_captures(git_dir: &Path, registry_root: &Path) -> usize {
598 match prune_or_fail(git_dir, registry_root) {
599 Ok(count) => return count,
600 Err(error) => {
601 warn(
602 "pruneLandedCaptures",
603 format!(
604 "could not prune landed-capture records in {}; they stay for the next pruning: {error}",
605 git_dir.display()
606 )
607 .as_str(),
608 );
609 return 0;
610 }
611 }
612}
613
614fn prune_or_fail(git_dir: &Path, registry_root: &Path) -> Result<usize, CaptureError> {
625 let names: Vec<String> = list_landed_record_names(git_dir)?;
626 if names.is_empty() {
627 return Ok(0);
628 }
629 let worktree_id: Option<String> = read_worktree_id(git_dir)?;
630 let Some(bound) = pinning_bound(registry_root, worktree_id.as_deref())? else {
631 debug(
632 "pruneLandedCaptures",
633 "a published transaction has not captured yet; keeping every landed-capture record",
634 );
635 return Ok(0);
636 };
637 let directory: PathBuf = landed_record_directory(git_dir);
638 let mut removed: usize = 0;
640 for name in &names {
641 if !prunable(
642 directory.as_path(),
643 name.as_str(),
644 worktree_id.as_deref(),
645 bound,
646 ) {
647 continue;
648 }
649 let path: PathBuf = directory.join(name);
650 match std::fs::remove_file(&path) {
651 Ok(()) => removed += 1,
652 Err(error) if error.kind() == std::io::ErrorKind::NotFound => removed += 1,
653 Err(error) => {
654 return Err(CaptureError(format!(
655 "removing {} failed: {error}",
656 path.display()
657 )));
658 }
659 }
660 }
661 debug(
662 "pruneLandedCaptures",
663 format!("pruned {removed} of {} landed-capture records", names.len()).as_str(),
664 );
665 return Ok(removed);
666}
667
668const _: () = assert!(MAX_SAFE_INTEGER < i64::MAX);
670
671#[cfg(test)]
673#[path = "capture_records_tests.rs"]
674mod tests;