use std::ffi::OsString;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{Shutdown, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use serde_json::Value;
use super::git_repo::GitRepo;
use super::shore;
pub struct WorktreeCapture {
pub _main: GitRepo,
pub _parent: tempfile::TempDir,
pub worktree: PathBuf,
pub revision_id: String,
}
impl WorktreeCapture {
pub fn on_branch(dir_name: &str, branch: &str) -> Self {
let main = GitRepo::new();
main.write("src/lib.rs", "pub fn value() -> u32 { 1 }\n");
main.commit_all("base");
let parent = tempfile::tempdir().expect("worktree parent");
let worktree = parent.path().join(dir_name);
add_worktree(main.path(), &worktree, branch);
std::fs::write(worktree.join("src/lib.rs"), "pub fn value() -> u32 { 2 }\n").unwrap();
let revision_id = capture(&worktree);
Self {
_main: main,
_parent: parent,
worktree,
revision_id,
}
}
}
pub struct Inspector {
child: Child,
addr: String,
stderr: Arc<Mutex<String>>,
_stdout_drain: thread::JoinHandle<()>,
}
impl Inspector {
pub fn spawn(repo: &Path) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_shore"))
.args([
"inspect",
"--repo",
repo.to_str().unwrap(),
"--host",
"127.0.0.1",
"--port",
"0",
])
.env_remove("SHORE_LOG")
.env_remove("RUST_LOG")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn shore inspect");
let stderr = Arc::new(Mutex::new(String::new()));
let mut child_stderr = child.stderr.take().expect("inspector stderr");
{
let sink = Arc::clone(&stderr);
thread::spawn(move || {
let mut buffer = String::new();
let _ = child_stderr.read_to_string(&mut buffer);
if let Ok(mut guard) = sink.lock() {
*guard = buffer;
}
});
}
let stdout = child.stdout.take().expect("inspector stdout");
let mut reader = BufReader::new(stdout);
let mut addr = String::new();
for _ in 0..8 {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) => {
if let Some(index) = line.find("http://") {
addr = line[index + "http://".len()..]
.trim()
.trim_end_matches('/')
.to_owned();
break;
}
}
}
}
let stdout_drain = thread::spawn(move || {
let mut sink = String::new();
let _ = reader.read_to_string(&mut sink);
});
assert!(
!addr.is_empty(),
"inspector did not print a bound url; stderr: {}",
drained(&stderr)
);
let mut ready = false;
for _ in 0..100 {
if TcpStream::connect(&addr).is_ok() {
ready = true;
break;
}
if let Ok(Some(status)) = child.try_wait() {
panic!(
"inspector exited before listening (status {status}) at {addr}; stderr: {}",
drained(&stderr)
);
}
thread::sleep(Duration::from_millis(20));
}
assert!(
ready,
"inspector never accepted a connection at {addr}; stderr: {}",
drained(&stderr)
);
Self {
child,
addr,
stderr,
_stdout_drain: stdout_drain,
}
}
pub fn get_json(&self, path: &str) -> Value {
let body = self.get_text(path);
serde_json::from_str(&body)
.unwrap_or_else(|error| panic!("parse {path} body: {error}\n{body}"))
}
pub fn get_text(&self, path: &str) -> String {
let mut last_error = String::new();
for attempt in 0..12 {
match self.try_get(path) {
Ok((_, body)) => return body,
Err(error) => {
last_error = error;
thread::sleep(Duration::from_millis(20 * (attempt + 1)));
}
}
}
panic!(
"GET {path} failed after retries: {last_error}; server stderr: {}",
drained(&self.stderr)
);
}
pub fn get_error(&self, path: &str) -> (String, Value) {
let (status, body) = self.raw_get(path);
let body: Value = serde_json::from_str(&body).expect("error body is json");
(status, body)
}
pub fn raw_get(&self, path: &str) -> (String, String) {
let mut last_error = String::new();
for attempt in 0..12 {
match self.try_raw_get(path) {
Ok(response) => return response,
Err(error) => {
last_error = error;
thread::sleep(Duration::from_millis(20 * (attempt + 1)));
}
}
}
panic!(
"GET {path} failed after retries: {last_error}; server stderr: {}",
drained(&self.stderr)
);
}
fn try_raw_get(&self, path: &str) -> Result<(String, String), String> {
let mut stream = TcpStream::connect(&self.addr).expect("connect to inspector");
let request = format!(
"GET {path} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
self.addr
);
stream
.write_all(request.as_bytes())
.map_err(|error| error.to_string())?;
let _ = stream.shutdown(Shutdown::Write);
let mut response = Vec::new();
stream
.read_to_end(&mut response)
.map_err(|error| error.to_string())?;
let text = String::from_utf8_lossy(&response);
let (head, body) = text
.split_once("\r\n\r\n")
.ok_or_else(|| format!("response has no header/body delimiter: {text}"))?;
let status = head.lines().next().unwrap_or_default().to_owned();
Ok((status, body.to_owned()))
}
pub fn request(&self, method: &str, path: &str) -> String {
let mut stream = TcpStream::connect(&self.addr).expect("connect to inspector");
let request = format!(
"{method} {path} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
self.addr
);
stream.write_all(request.as_bytes()).expect("send request");
let _ = stream.shutdown(Shutdown::Write);
let mut response = Vec::new();
stream.read_to_end(&mut response).expect("read response");
let text = String::from_utf8_lossy(&response);
text.lines().next().unwrap_or_default().to_owned()
}
fn try_get(&self, path: &str) -> Result<(String, String), String> {
let mut stream = TcpStream::connect(&self.addr).map_err(|error| error.to_string())?;
let request = format!(
"GET {path} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
self.addr
);
stream
.write_all(request.as_bytes())
.map_err(|error| error.to_string())?;
let _ = stream.shutdown(Shutdown::Write);
let mut response = Vec::new();
stream
.read_to_end(&mut response)
.map_err(|error| error.to_string())?;
let text = String::from_utf8_lossy(&response);
let (head, body) = text
.split_once("\r\n\r\n")
.ok_or_else(|| "response had no header/body delimiter".to_owned())?;
if !head.starts_with("HTTP/1.1 200") {
return Err(format!("unexpected status for {path}: {head}"));
}
Ok((head.to_owned(), body.to_owned()))
}
}
impl Drop for Inspector {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn drained(stderr: &Arc<Mutex<String>>) -> String {
thread::sleep(Duration::from_millis(50));
stderr.lock().map(|guard| guard.clone()).unwrap_or_default()
}
pub fn capture(repo: &Path) -> String {
let output = shore(["capture", "--repo", repo.to_str().unwrap(), "--allow-empty"]);
assert!(
output.status.success(),
"capture stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let json: Value = serde_json::from_slice(&output.stdout).expect("parse capture JSON");
json["revision"]["id"]
.as_str()
.expect("capture returns a Revision id")
.to_owned()
}
pub struct RepresentativeStore {
pub repo: GitRepo,
pub revision_id: String,
pub snapshot_id: String,
}
pub fn representative_store() -> RepresentativeStore {
let repo = GitRepo::new();
repo.write(
"src/lib.rs",
"pub fn value() -> u32 {\n 1\n}\n\npub fn other() -> u32 {\n 2\n}\n",
);
repo.commit_all("base");
repo.write(
"src/lib.rs",
"pub fn value() -> u32 {\n 42\n}\n\npub fn other() -> u32 {\n 7\n}\n",
);
let repo_arg = repo.path().to_str().unwrap().to_owned();
let capture = run_shore_json(&["capture", "--repo", &repo_arg]);
let revision_id = capture["revision"]["id"]
.as_str()
.expect("capture returns a Revision id")
.to_owned();
let snapshot_id = capture["revision"]["objectId"]
.as_str()
.expect("capture returns a snapshot id")
.to_owned();
run_shore(&[
"observation",
"add",
"--repo",
&repo_arg,
"--track",
"agent:codex",
"--title",
"Observed change",
"--body",
"the return value changed",
"--file",
"src/lib.rs",
"--start-line",
"2",
"--end-line",
"2",
]);
run_shore(&[
"input-request",
"open",
"--repo",
&repo_arg,
"--track",
"agent:codex",
"--title",
"Need a decision",
"--reason",
"manual-decision-required",
"--body",
"should we ship this?",
]);
let first = run_shore_json(&[
"assessment",
"add",
"--repo",
&repo_arg,
"--track",
"agent:codex",
"--assessment",
"needs-changes",
"--summary",
"not yet",
]);
let first_assessment_id = first["assessmentId"]
.as_str()
.expect("assessment add returns an assessment id")
.to_owned();
run_shore(&[
"assessment",
"add",
"--repo",
&repo_arg,
"--track",
"human:kevin",
"--assessment",
"accepted",
"--summary",
"ship it",
"--replaces",
&first_assessment_id,
]);
run_shore(&[
"validation",
"add",
"--repo",
&repo_arg,
"--track",
"agent:codex",
"--check-name",
"cargo test",
"--status",
"passed",
]);
run_shore(&[
"validation",
"add",
"--repo",
&repo_arg,
"--track",
"human:kevin",
"--check-name",
"cargo clippy",
"--status",
"failed",
"--exit-code",
"1",
"--command",
"cargo clippy -- -D warnings",
]);
RepresentativeStore {
repo,
revision_id,
snapshot_id,
}
}
fn run_shore(args: &[&str]) {
let output = shore(args);
assert!(
output.status.success(),
"shore {args:?} failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
fn run_shore_json(args: &[&str]) -> Value {
let output = shore(args);
assert!(
output.status.success(),
"shore {args:?} failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout)
.unwrap_or_else(|error| panic!("parse shore {args:?} JSON: {error}"))
}
pub fn capture_supersession_round(repo: &Path, predecessor: Option<&str>) -> String {
let mut args = vec![
"capture".to_owned(),
"--repo".to_owned(),
repo.to_str().unwrap().to_owned(),
];
if let Some(predecessor) = predecessor {
let target = repo.join("src/lib.rs");
let mut contents = std::fs::read_to_string(&target).unwrap_or_default();
contents.push_str(&format!(
"\n// supersedes {}\n",
predecessor.replace(':', "_")
));
std::fs::write(&target, contents).expect("write successor content");
args.push("--supersedes".to_owned());
args.push(predecessor.to_owned());
} else {
args.push("--allow-empty".to_owned());
}
let output = shore(args);
assert!(
output.status.success(),
"capture supersession round stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let json: Value =
serde_json::from_slice(&output.stdout).expect("parse supersession capture JSON");
json["revision"]["id"]
.as_str()
.expect("supersession capture returns a Revision id")
.to_owned()
}
pub fn add_worktree(repo: &Path, path: &Path, branch: &str) {
run_git(
repo,
[
OsString::from("worktree"),
OsString::from("add"),
OsString::from("-b"),
OsString::from(branch),
path.as_os_str().to_owned(),
],
);
}
pub fn run_git<I>(cwd: &Path, args: I)
where
I: IntoIterator<Item = OsString>,
{
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.unwrap_or_else(|error| panic!("run git in {}: {error}", cwd.display()));
assert!(
output.status.success(),
"git failed in {}:\n{}",
cwd.display(),
String::from_utf8_lossy(&output.stderr)
);
}
pub fn urlencode(value: &str) -> String {
value.replace(':', "%3A")
}