use serde::Deserialize;
use std::io::{self, Read, Write};
use std::path::Path;
use thiserror::Error;
use super::super::ENV_CONV_REPO;
use super::super::bound;
use super::dispatch::EnvLookup;
use crate::config::ToolOutputBound;
use crate::template::{GitRunner, RealGit};
use crate::workspace;
const PREVIEW_COUNT: usize = 5;
const PREVIEW_BOUND: ToolOutputBound = ToolOutputBound {
head_bytes: 4096,
tail_bytes: 4096,
};
const ENTRY_DIRS: [&str; 2] = ["messages", "summary"];
const AGENT_BRANCHES: &str = "--branches=agents/*";
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Input {
pattern: Option<String>,
entry: Option<String>,
}
#[derive(Debug, Error)]
pub enum Error {
#[error("invalid input JSON: {0}")]
InvalidJson(#[source] serde_json::Error),
#[error("read input from stdin: {0}")]
StdinRead(#[source] io::Error),
#[error("missing env var {0:?} (set by the harness per ARCH §3.3)")]
MissingEnv(&'static str),
#[error(
"give exactly one of {{\"pattern\": \"<text>\"}} (search) or \
{{\"entry\": \"<commit>:<path>\"}} (recover one entry)"
)]
Ambiguous,
#[error("{op}: {source}")]
Git {
op: &'static str,
#[source]
source: io::Error,
},
#[error("write to stdout: {0}")]
Write(#[source] io::Error),
}
struct Hit {
commit: String,
path: String,
blob: String,
}
impl Hit {
fn address(&self) -> String {
format!("{}:{}", self.commit, self.path)
}
}
pub fn run<R: Read, W: Write>(
stdin: &mut R,
stdout: &mut W,
env: &dyn EnvLookup,
) -> Result<(), Error> {
let git = &RealGit::new();
let mut buf = Vec::new();
stdin.read_to_end(&mut buf).map_err(Error::StdinRead)?;
let input: Input = serde_json::from_slice(&buf).map_err(Error::InvalidJson)?;
let repo = env
.get(ENV_CONV_REPO)
.ok_or(Error::MissingEnv(ENV_CONV_REPO))?;
let repo = workspace::repo_git(Path::new(&repo));
let out = match (input.pattern, input.entry) {
(Some(pattern), None) => search(&repo, &pattern, git)?,
(None, Some(address)) => blob(&repo, &address, git)?,
_ => return Err(Error::Ambiguous),
};
stdout.write_all(&out).map_err(Error::Write)
}
fn search(repo: &Path, pattern: &str, git: &dyn GitRunner) -> Result<Vec<u8>, Error> {
let pickaxe = format!("-S{pattern}");
let mut args = vec![
"log",
AGENT_BRANCHES,
"--diff-filter=A",
"--format=%H",
"--raw",
"--no-abbrev",
pickaxe.as_str(),
"--",
];
args.extend_from_slice(&ENTRY_DIRS);
let log = git.run_capture(repo, &args).map_err(|source| Error::Git {
op: "search_history log",
source,
})?;
let hits = parse(&log);
let mut out = Vec::new();
for hit in &hits {
out.extend_from_slice(hit.address().as_bytes());
out.push(b'\n');
}
for hit in hits.iter().take(PREVIEW_COUNT) {
let address = hit.address();
let content = blob(repo, &address, git)?;
let origin = bound::Origin::Named(Path::new(&address));
let bounded = bound::apply(&content, "entry", Some(PREVIEW_BOUND), origin);
out.extend_from_slice(format!("\n<entry address=\"{address}\">\n").as_bytes());
out.extend_from_slice(&bounded);
out.extend_from_slice(b"\n</entry>\n");
}
Ok(out)
}
fn blob(repo: &Path, address: &str, git: &dyn GitRunner) -> Result<Vec<u8>, Error> {
git.run_capture_bytes(repo, &["show", address])
.map_err(|source| Error::Git {
op: "search_history show",
source,
})
}
fn parse(log: &str) -> Vec<Hit> {
let mut hits: Vec<Hit> = Vec::new();
let mut commit = String::new();
for line in log.lines().filter(|l| !l.trim().is_empty()) {
let Some(raw) = line.strip_prefix(':') else {
commit = line.trim().to_string();
continue;
};
let Some((fields, path)) = raw.split_once('\t') else {
continue;
};
let Some(blob) = fields.split_whitespace().nth(3) else {
continue;
};
if hits.iter().any(|h| h.path == path && h.blob == blob) {
continue;
}
hits.push(Hit {
commit: commit.clone(),
path: path.to_string(),
blob: blob.to_string(),
});
}
hits
}
#[cfg(test)]
mod tests;