use serde_json::Value;
use std::io::Read;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::mpsc::{sync_channel, RecvTimeoutError};
use std::time::{Duration, Instant};
const MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024;
#[derive(Debug)]
pub enum GatewayError {
BinaryNotFound(String),
Spawn(String),
Timeout(String),
Failed {
status: String,
stderr: String,
},
Parse(String),
}
impl std::fmt::Display for GatewayError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BinaryNotFound(path) => write!(f, "grokctl not found (looked for {path})"),
Self::Spawn(message) => write!(f, "could not run grokctl: {message}"),
Self::Timeout(command) => write!(f, "grokctl {command} timed out"),
Self::Failed { status, stderr } => {
let detail = stderr.trim();
if detail.is_empty() {
write!(f, "grokctl exited with {status}")
} else {
write!(f, "grokctl exited with {status}: {detail}")
}
}
Self::Parse(message) => write!(f, "could not parse grokctl output: {message}"),
}
}
}
pub struct GrokctlClient {
binary: PathBuf,
timeout: Duration,
}
impl GrokctlClient {
pub fn discover(explicit: Option<&str>, timeout: Duration) -> Result<Self, GatewayError> {
let candidates: Vec<PathBuf> = match explicit {
Some(path) => vec![PathBuf::from(path)],
None => std::env::var_os("DEVSQL_GROKCTL_BIN")
.map(PathBuf::from)
.into_iter()
.chain(std::iter::once(PathBuf::from("grokctl")))
.chain(dirs::home_dir().map(|home| home.join(".cargo").join("bin").join("grokctl")))
.collect(),
};
for candidate in &candidates {
if candidate.components().count() == 1 || candidate.exists() {
let client = Self {
binary: candidate.clone(),
timeout,
};
if client.is_runnable() {
return Ok(client);
}
}
}
Err(GatewayError::BinaryNotFound(
candidates
.first()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "grokctl".to_string()),
))
}
fn is_runnable(&self) -> bool {
Command::new(&self.binary)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
pub fn list_bots(&self) -> Result<Vec<Value>, GatewayError> {
let value = self.run(&["bot", "list", "--format", "json"])?;
Ok(value.as_array().cloned().unwrap_or_default())
}
pub fn transcript_page(
&self,
bot_id: &str,
limit: i64,
before_seq: Option<i64>,
) -> Result<TranscriptPage, GatewayError> {
let mut body = serde_json::Map::new();
body.insert("id".to_string(), Value::String(bot_id.to_string()));
body.insert("limit".to_string(), Value::from(limit));
if let Some(seq) = before_seq {
body.insert("beforeSeq".to_string(), Value::from(seq));
}
let body = Value::Object(body).to_string();
let value = self.run(&[
"bot",
"transcript-tail",
"--body",
&body,
"--format",
"json",
])?;
let result = value.get("result").unwrap_or(&value);
let entries = result
.get("entries")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let next_before_seq = result.get("nextBeforeSeq").and_then(Value::as_i64);
Ok(TranscriptPage {
entries,
next_before_seq,
})
}
fn run(&self, args: &[&str]) -> Result<Value, GatewayError> {
let stdout = self.capture(args)?;
serde_json::from_slice::<Value>(&stdout).map_err(|error| {
let preview: String = String::from_utf8_lossy(&stdout).chars().take(200).collect();
GatewayError::Parse(format!("{error} (output began: {preview:?})"))
})
}
fn capture(&self, args: &[&str]) -> Result<Vec<u8>, GatewayError> {
let mut child = Command::new(&self.binary)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| GatewayError::Spawn(error.to_string()))?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let (sender, receiver) = sync_channel(2);
let stdout_sender = sender.clone();
let stdout_thread = std::thread::spawn(move || {
let mut buffer = Vec::new();
if let Some(mut stream) = stdout {
let _ = stream
.by_ref()
.take(MAX_OUTPUT_BYTES as u64)
.read_to_end(&mut buffer);
}
let _ = stdout_sender.send(Stream::Stdout(buffer));
});
let stderr_thread = std::thread::spawn(move || {
let mut buffer = Vec::new();
if let Some(mut stream) = stderr {
let _ = stream
.by_ref()
.take(MAX_OUTPUT_BYTES as u64)
.read_to_end(&mut buffer);
}
let _ = sender.send(Stream::Stderr(buffer));
});
let deadline = Instant::now() + self.timeout;
let mut out = Vec::new();
let mut err = Vec::new();
let mut seen = 0;
let mut timed_out = false;
while seen < 2 {
let remaining = deadline.saturating_duration_since(Instant::now());
match receiver.recv_timeout(remaining) {
Ok(Stream::Stdout(buffer)) => {
out = buffer;
seen += 1;
}
Ok(Stream::Stderr(buffer)) => {
err = buffer;
seen += 1;
}
Err(RecvTimeoutError::Timeout) => {
timed_out = true;
break;
}
Err(RecvTimeoutError::Disconnected) => break,
}
}
if timed_out {
let _ = child.kill();
let _ = child.wait();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
return Err(GatewayError::Timeout(args.join(" ")));
}
let status = child
.wait()
.map_err(|error| GatewayError::Spawn(error.to_string()))?;
let _ = stdout_thread.join();
let _ = stderr_thread.join();
if !status.success() {
return Err(GatewayError::Failed {
status: status.to_string(),
stderr: String::from_utf8_lossy(&err).into_owned(),
});
}
Ok(out)
}
}
enum Stream {
Stdout(Vec<u8>),
Stderr(Vec<u8>),
}
#[derive(Debug, Default)]
pub struct TranscriptPage {
pub entries: Vec<Value>,
pub next_before_seq: Option<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_binary_is_reported_not_panicked() {
let error = GrokctlClient::discover(
Some("/nonexistent/grokctl-does-not-exist"),
Duration::from_secs(1),
);
assert!(matches!(error, Err(GatewayError::BinaryNotFound(_))));
}
}