1use std::collections::BTreeMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Value};
13use sha2::{Digest, Sha256};
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum ManifestKind {
18 Directory,
19 File,
20 Symlink,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct ManifestEntry {
25 pub kind: ManifestKind,
26 pub size: u64,
27 pub sha256: Option<String>,
28 pub link_target: Option<String>,
29}
30
31pub type FilesystemManifest = BTreeMap<String, ManifestEntry>;
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
34pub struct StructuredObservation {
35 pub stdout: Vec<u8>,
36 pub stderr: Vec<u8>,
37 pub exit_code: Option<i32>,
38 pub entries: Vec<(String, bool)>,
39 pub selected_paths: Vec<String>,
40 pub matches: Vec<(String, u32, String)>,
41 pub values: Vec<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
45pub struct Observation {
46 pub raw_stdout: Vec<u8>,
47 pub raw_stderr: Vec<u8>,
48 pub exit_code: Option<i32>,
49 pub structured: StructuredObservation,
50 pub filesystem: FilesystemManifest,
51}
52
53pub trait ObservationAdapter {
54 fn adapt(&self, stdout: &[u8], stderr: &[u8], exit_code: Option<i32>) -> Observation;
55}
56
57#[derive(Debug, Clone, Copy, Default)]
58pub struct ByteObservationAdapter;
59
60impl ObservationAdapter for ByteObservationAdapter {
61 fn adapt(&self, stdout: &[u8], stderr: &[u8], exit_code: Option<i32>) -> Observation {
62 Observation {
63 raw_stdout: stdout.to_vec(),
64 raw_stderr: stderr.to_vec(),
65 exit_code,
66 structured: StructuredObservation {
67 stdout: stdout.to_vec(),
68 stderr: stderr.to_vec(),
69 exit_code,
70 ..StructuredObservation::default()
71 },
72 filesystem: FilesystemManifest::default(),
73 }
74 }
75}
76
77pub fn observation_from_process(
78 stdout: &[u8],
79 stderr: &[u8],
80 exit_code: Option<i32>,
81 root: &Path,
82) -> Observation {
83 let mut observation = ByteObservationAdapter.adapt(stdout, stderr, exit_code);
84 observation.filesystem = deterministic_filesystem_manifest(root).unwrap_or_default();
85 observation
86}
87
88pub fn observation_from_aft_response(
91 response: &Value,
92 root: &Path,
93 exit_code: Option<i32>,
94) -> Observation {
95 let output = response
96 .get("output")
97 .or_else(|| response.get("text"))
98 .or_else(|| response.get("content"))
99 .and_then(Value::as_str)
100 .unwrap_or_default()
101 .as_bytes()
102 .to_vec();
103 let mut observation = observation_from_process(&output, &[], exit_code, root);
104 if let Some(entries) = response.get("entries").and_then(Value::as_array) {
105 observation.structured.entries = entries
106 .iter()
107 .filter_map(|entry| {
108 if let Some(name) = entry.as_str() {
109 Some((name.to_string(), false))
110 } else {
111 let object = entry.as_object()?;
112 Some((
113 object.get("name")?.as_str()?.to_string(),
114 object
115 .get("is_dir")
116 .and_then(Value::as_bool)
117 .unwrap_or(false),
118 ))
119 }
120 })
121 .collect();
122 }
123 if let Some(files) = response.get("files").and_then(Value::as_array) {
124 let canonical_root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
125 observation.structured.selected_paths = files
126 .iter()
127 .filter_map(Value::as_str)
128 .map(|path| {
129 let path = Path::new(path);
130 let relative = path
131 .strip_prefix(root)
132 .or_else(|_| path.strip_prefix(&canonical_root))
133 .unwrap_or(path);
134 relative
135 .to_string_lossy()
136 .replace(std::path::MAIN_SEPARATOR, "/")
137 })
138 .collect();
139 }
140 observation
141}
142
143pub fn deterministic_filesystem_manifest(root: &Path) -> std::io::Result<FilesystemManifest> {
144 let mut manifest = FilesystemManifest::new();
145 if !root.exists() {
146 return Ok(manifest);
147 }
148 walk_manifest(root, root, &mut manifest)?;
149 Ok(manifest)
150}
151
152fn walk_manifest(
153 root: &Path,
154 current: &Path,
155 manifest: &mut FilesystemManifest,
156) -> std::io::Result<()> {
157 let mut entries = fs::read_dir(current)?.collect::<Result<Vec<_>, _>>()?;
158 entries.sort_by(|left, right| left.file_name().cmp(&right.file_name()));
159 for entry in entries {
160 let path = entry.path();
161 let relative = path
162 .strip_prefix(root)
163 .unwrap_or(&path)
164 .to_string_lossy()
165 .replace(std::path::MAIN_SEPARATOR, "/");
166 let metadata = fs::symlink_metadata(&path)?;
167 let file_type = metadata.file_type();
168 if file_type.is_symlink() {
169 manifest.insert(
170 relative,
171 ManifestEntry {
172 kind: ManifestKind::Symlink,
173 size: metadata.len(),
174 sha256: None,
175 link_target: fs::read_link(&path)
176 .ok()
177 .map(|target| target.to_string_lossy().into_owned()),
178 },
179 );
180 } else if file_type.is_dir() {
181 manifest.insert(
182 relative,
183 ManifestEntry {
184 kind: ManifestKind::Directory,
185 size: 0,
186 sha256: None,
187 link_target: None,
188 },
189 );
190 walk_manifest(root, &path, manifest)?;
191 } else if file_type.is_file() {
192 let bytes = fs::read(&path)?;
193 let digest = Sha256::digest(&bytes);
194 manifest.insert(
195 relative,
196 ManifestEntry {
197 kind: ManifestKind::File,
198 size: bytes.len() as u64,
199 sha256: Some(hex_digest(&digest)),
200 link_target: None,
201 },
202 );
203 }
204 }
205 Ok(())
206}
207
208fn hex_digest(bytes: &[u8]) -> String {
209 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
210}
211
212pub fn manifests_equal(left: &FilesystemManifest, right: &FilesystemManifest) -> bool {
213 left == right
214}
215
216pub fn manifest_unchanged(before: &FilesystemManifest, after: &FilesystemManifest) -> bool {
217 manifests_equal(before, after)
218}
219
220pub fn apply_presentation_normalizations(
221 bytes: &[u8],
222 normalizations: &[&str],
223) -> Result<Vec<u8>, String> {
224 let mut output = bytes.to_vec();
225 for normalization in normalizations {
226 output = match *normalization {
227 "footer-removal" => remove_footer(&output),
228 "gutter-removal" => remove_gutter(&output),
229 other => return Err(format!("unknown presentation normalization: {other}")),
230 };
231 }
232 Ok(output)
233}
234
235fn remove_footer(bytes: &[u8]) -> Vec<u8> {
236 let had_newline = bytes.ends_with(b"\n");
237 let text = String::from_utf8_lossy(bytes);
238 let mut lines = text.lines().collect::<Vec<_>>();
239 if let Some(index) = lines.iter().position(|line| {
240 line.contains("Prefer `") || line.contains("DO NOT search code by running grep/rg in bash")
241 }) {
242 lines.truncate(index);
243 while lines.last().is_some_and(|line| line.is_empty()) {
244 lines.pop();
245 }
246 }
247 let mut output = lines.join("\n").into_bytes();
248 if had_newline {
249 output.push(b'\n');
250 }
251 output
252}
253
254fn remove_gutter(bytes: &[u8]) -> Vec<u8> {
255 let text = String::from_utf8_lossy(bytes);
256 let had_newline = bytes.ends_with(b"\n")
257 || text.lines().any(|line| {
258 line.contains("Prefer `")
259 || line.contains("DO NOT search code by running grep/rg in bash")
260 });
261 let mut output = text
262 .lines()
263 .map(|line| {
264 let Some((number, content)) = line.split_once(": ") else {
265 return line;
266 };
267 if !number.is_empty() && number.chars().all(|char| char.is_ascii_digit()) {
268 content
269 } else {
270 line
271 }
272 })
273 .collect::<Vec<_>>()
274 .join("\n")
275 .into_bytes();
276 if had_newline {
277 output.push(b'\n');
278 }
279 output
280}
281
282pub fn reduce_observation(
283 observation: &Observation,
284 basis: &str,
285 normalizations: &[&str],
286) -> Result<Value, String> {
287 let stdout = apply_presentation_normalizations(&observation.raw_stdout, normalizations)?;
288 let stderr = apply_presentation_normalizations(&observation.raw_stderr, normalizations)?;
289 let stdout_text = String::from_utf8_lossy(&stdout);
290 match basis {
291 "bytes" => Ok(json!({
292 "stdout": stdout,
293 "stderr": stderr,
294 "exit_code": observation.exit_code,
295 })),
296 "ls-entry-set" => {
297 let mut entries = if observation.structured.entries.is_empty() {
298 stdout_text
299 .lines()
300 .filter(|line| !line.is_empty())
301 .map(|line| (line.to_string(), false))
302 .collect::<Vec<_>>()
303 } else {
304 observation.structured.entries.clone()
305 };
306 entries.sort();
307 entries.dedup();
308 Ok(json!(entries))
309 }
310 "ls-entry-sequence" => {
311 let entries = if observation.structured.entries.is_empty() {
312 stdout_text
313 .lines()
314 .filter(|line| !line.is_empty())
315 .map(|line| (line.to_string(), false))
316 .collect::<Vec<_>>()
317 } else {
318 observation.structured.entries.clone()
319 };
320 Ok(json!(entries))
321 }
322 "find-path-set" => {
323 let paths = if observation.structured.selected_paths.is_empty() {
324 stdout_text
325 .lines()
326 .filter(|line| !line.is_empty())
327 .map(str::to_owned)
328 .collect::<Vec<_>>()
329 } else {
330 observation.structured.selected_paths.clone()
331 };
332 let paths = paths
333 .into_iter()
334 .map(|path| path.strip_prefix("./").unwrap_or(&path).to_string())
335 .collect::<Vec<_>>();
336 let mut paths = paths;
337 paths.sort();
338 paths.dedup();
339 Ok(json!(paths))
340 }
341 "grep-match-set" => {
342 let mut matches = if observation.structured.matches.is_empty() {
343 parse_grep_matches(&stdout_text)
344 } else {
345 observation.structured.matches.clone()
346 };
347 matches.sort();
348 matches.dedup();
349 Ok(json!(matches))
350 }
351 "grep-value-multiset" => {
352 let mut values = if observation.structured.values.is_empty() {
353 stdout_text.lines().map(str::to_owned).collect::<Vec<_>>()
354 } else {
355 observation.structured.values.clone()
356 };
357 values.sort();
358 Ok(json!(values))
359 }
360 other => Err(format!("unknown comparison basis: {other}")),
361 }
362}
363
364fn parse_grep_matches(text: &str) -> Vec<(String, u32, String)> {
365 text.lines()
366 .filter_map(|line| {
367 let (file, rest) = line.split_once(':')?;
368 let (number, content) = rest.split_once(':')?;
369 Some((file.to_string(), number.parse().ok()?, content.to_string()))
370 })
371 .collect()
372}
373
374pub fn observation_summary(observation: &Observation) -> Value {
377 json!({
378 "stdout": String::from_utf8_lossy(&observation.raw_stdout),
379 "stderr": String::from_utf8_lossy(&observation.raw_stderr),
380 "exit_code": observation.exit_code,
381 "filesystem_entries": observation.filesystem.len(),
382 "structured": observation.structured,
383 })
384}
385
386#[allow(dead_code)]
387fn _normalize_path(root: &Path, path: &Path) -> PathBuf {
388 path.strip_prefix(root).unwrap_or(path).to_path_buf()
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn footer_and_gutter_normalizations_are_explicit() {
397 let raw = b"1: alpha\nPrefer `read` tool over bash.\n";
398 let normalized =
399 apply_presentation_normalizations(raw, &["gutter-removal", "footer-removal"]).unwrap();
400 assert_eq!(normalized, b"alpha\n");
401 }
402
403 #[test]
404 fn filesystem_manifest_is_sorted_and_exact() {
405 let root = tempfile::tempdir().unwrap();
406 fs::create_dir(root.path().join("z")).unwrap();
407 fs::write(root.path().join("z/a"), b"a").unwrap();
408 fs::write(root.path().join("b"), b"b").unwrap();
409 let manifest = deterministic_filesystem_manifest(root.path()).unwrap();
410 assert_eq!(manifest.keys().collect::<Vec<_>>(), vec!["b", "z", "z/a"]);
411 assert!(manifest_unchanged(&manifest, &manifest));
412 }
413
414 #[test]
415 fn reducers_do_not_accept_unknown_vocabularies() {
416 let observation = ByteObservationAdapter.adapt(b"x\n", b"", Some(0));
417 assert!(reduce_observation(&observation, "not-a-basis", &[]).is_err());
418 assert!(apply_presentation_normalizations(b"x", &["bytes"]).is_err());
419 }
420}