use serde::{Deserialize, Serialize};
use std::ffi::OsString;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use thiserror::Error;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Input {
role: String,
goal: String,
#[serde(default)]
name: Option<String>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
struct Output<'a> {
status: &'a str,
handle: &'a str,
}
const STATUS_IN_PROGRESS: &str = "in_progress";
#[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(transparent)]
Role(#[from] crate::prompt::role::validate::Invalid),
#[error("spawn lernie dispatch {role:?}: {source}")]
Spawn {
role: String,
#[source]
source: io::Error,
},
#[error("lernie dispatch {role:?} failed (exit {exit}): {stderr}")]
DispatchExit {
role: String,
exit: i32,
stderr: String,
},
#[error("lernie dispatch {role:?} produced no handle on stdout")]
EmptyHandle { role: String },
#[error("write to stdout: {0}")]
Write(#[source] io::Error),
}
pub trait Spawner {
fn dispatch(
&self,
role: &str,
repo: &Path,
branch: &str,
goal: &str,
name: Option<&str>,
) -> Result<DispatchOutput, io::Error>;
}
#[derive(Debug)]
pub struct DispatchOutput {
pub stdout: String,
pub stderr: String,
pub exit: i32,
}
pub struct SubprocessSpawner {
exe: PathBuf,
}
impl SubprocessSpawner {
pub fn with_exe(exe: PathBuf) -> Self {
Self { exe }
}
}
impl Spawner for SubprocessSpawner {
fn dispatch(
&self,
role: &str,
repo: &Path,
branch: &str,
goal: &str,
name: Option<&str>,
) -> Result<DispatchOutput, io::Error> {
let mut cmd = Command::new(&self.exe);
cmd.args(["dispatch", role])
.arg(repo)
.arg(branch)
.args(["--goal", goal]);
if let Some(name) = name {
cmd.args(["--name", name]);
}
let out = cmd.output()?;
Ok(DispatchOutput {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
exit: out.status.code().unwrap_or(-1),
})
}
}
pub trait EnvLookup {
fn get(&self, key: &str) -> Option<OsString>;
}
pub struct ProcessEnv;
impl EnvLookup for ProcessEnv {
fn get(&self, key: &str) -> Option<OsString> {
std::env::var_os(key)
}
}
pub fn run<R: Read, W: Write>(
stdin: &mut R,
stdout: &mut W,
env: &dyn EnvLookup,
dispatcher: &dyn Spawner,
) -> Result<(), Error> {
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 = require_env(env, super::super::ENV_CONV_REPO)?;
let branch = require_env(env, super::super::ENV_CONV_BRANCH)?;
let repo_path = PathBuf::from(repo);
let branch_str = branch
.into_string()
.map_err(|_| Error::MissingEnv(super::super::ENV_CONV_BRANCH))?;
crate::prompt::role::validate::validate(
&repo_path,
&branch_str,
None,
&input.role,
&crate::template::RealGit::new(),
)?;
let captured = dispatcher
.dispatch(
&input.role,
&repo_path,
&branch_str,
&input.goal,
input.name.as_deref(),
)
.map_err(|source| Error::Spawn {
role: input.role.clone(),
source,
})?;
if captured.exit != 0 {
return Err(Error::DispatchExit {
role: input.role,
exit: captured.exit,
stderr: captured.stderr,
});
}
let handle = captured.stdout.trim();
if handle.is_empty() {
return Err(Error::EmptyHandle { role: input.role });
}
let payload = Output {
status: STATUS_IN_PROGRESS,
handle,
};
let bytes = serde_json::to_vec(&payload).expect("Output is always serializable");
stdout.write_all(&bytes).map_err(Error::Write)
}
fn require_env(env: &dyn EnvLookup, key: &'static str) -> Result<OsString, Error> {
env.get(key).ok_or(Error::MissingEnv(key))
}
#[cfg(test)]
mod tests;