ralph_workflow/checkpoint/execution_history/
file_snapshot.rs1const DEFAULT_CONTENT_THRESHOLD: u64 = 10 * 1024;
6
7const MAX_COMPRESS_SIZE: u64 = 100 * 1024;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
15pub struct FileSnapshot {
16 pub path: String,
18 pub checksum: String,
20 pub size: u64,
22 pub content: Option<String>,
24 pub compressed_content: Option<String>,
26 pub exists: bool,
28}
29
30impl FileSnapshot {
31 #[must_use]
36 pub fn new(path: &str, checksum: String, size: u64, exists: bool) -> Self {
37 Self {
38 path: path.to_string(),
39 checksum,
40 size,
41 content: None,
42 compressed_content: None,
43 exists,
44 }
45 }
46
47 pub fn from_workspace_default(
53 workspace: &dyn Workspace,
54 path: &str,
55 checksum: String,
56 size: u64,
57 exists: bool,
58 ) -> Self {
59 Self::from_workspace(
60 workspace,
61 path,
62 checksum,
63 size,
64 exists,
65 DEFAULT_CONTENT_THRESHOLD,
66 )
67 }
68
69 pub fn from_workspace(
75 workspace: &dyn Workspace,
76 path: &str,
77 checksum: String,
78 size: u64,
79 exists: bool,
80 max_size: u64,
81 ) -> Self {
82 let mut content = None;
83 let mut compressed_content = None;
84
85 if exists {
86 let is_key_file = path.contains("PROMPT.md")
87 || path.contains("PLAN.md")
88 || path.contains("ISSUES.md")
89 || path.contains("NOTES.md");
90
91 let path_ref = Path::new(path);
92
93 if size < max_size {
94 content = workspace.read(path_ref).ok();
96 } else if is_key_file && size < MAX_COMPRESS_SIZE {
97 if let Ok(data) = workspace.read_bytes(path_ref) {
99 compressed_content = compress_data(&data).ok();
100 }
101 }
102 }
103
104 Self {
105 path: path.to_string(),
106 checksum,
107 size,
108 content,
109 compressed_content,
110 exists,
111 }
112 }
113
114 #[must_use]
116 pub fn get_content(&self) -> Option<String> {
117 self.content.clone().or_else(|| {
118 self.compressed_content
119 .as_ref()
120 .and_then(|compressed| decompress_data(compressed).ok())
121 })
122 }
123
124 #[must_use]
126 pub fn not_found(path: &str) -> Self {
127 Self {
128 path: path.to_string(),
129 checksum: String::new(),
130 size: 0,
131 content: None,
132 compressed_content: None,
133 exists: false,
134 }
135 }
136
137 pub fn verify_with_workspace(&self, workspace: &dyn Workspace) -> bool {
139 let path = Path::new(&self.path);
140
141 if !self.exists {
142 return !workspace.exists(path);
143 }
144
145 let Ok(content) = workspace.read_bytes(path) else {
146 return false;
147 };
148
149 if content.len() as u64 != self.size {
150 return false;
151 }
152
153 let checksum = crate::checkpoint::state::calculate_checksum_from_bytes(&content);
154 checksum == self.checksum
155 }
156}
157
158fn compress_data(data: &[u8]) -> Result<String, std::io::Error> {
163 use base64::{engine::general_purpose::STANDARD, Engine};
164 use flate2::write::GzEncoder;
165 use flate2::Compression;
166 use std::io::Write;
167
168 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
169 encoder.write_all(data)?;
170 let compressed = encoder.finish()?;
171
172 Ok(STANDARD.encode(&compressed))
173}
174
175const MAX_DECOMPRESSED_SNAPSHOT_BYTES: usize = 1024 * 1024;
176
177fn decompress_data(encoded: &str) -> Result<String, std::io::Error> {
179 use base64::{engine::general_purpose::STANDARD, Engine};
180 use flate2::read::GzDecoder;
181 use std::io::Read;
182
183 let compressed = STANDARD.decode(encoded).map_err(|e| {
184 std::io::Error::new(
185 std::io::ErrorKind::InvalidData,
186 format!("Base64 decode error: {e}"),
187 )
188 })?;
189
190 let mut decoder = GzDecoder::new(compressed.as_slice());
191 let mut decompressed = Vec::new();
192 let mut buf = [0u8; 8 * 1024];
193
194 loop {
195 let n = decoder.read(&mut buf)?;
196 if n == 0 {
197 break;
198 }
199
200 if decompressed.len().saturating_add(n) > MAX_DECOMPRESSED_SNAPSHOT_BYTES {
201 return Err(std::io::Error::new(
202 std::io::ErrorKind::InvalidData,
203 format!(
204 "Decompressed payload exceeds max size ({MAX_DECOMPRESSED_SNAPSHOT_BYTES} bytes)"
205 ),
206 ));
207 }
208
209 decompressed.extend_from_slice(&buf[..n]);
210 }
211
212 String::from_utf8(decompressed).map_err(|e| {
213 std::io::Error::new(
214 std::io::ErrorKind::InvalidData,
215 format!("UTF-8 decode error: {e}"),
216 )
217 })
218}