use serde::{Deserialize, Serialize};
use std::ffi::OsString;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use thiserror::Error;
mod homes;
use super::super::{ENV_CONV_BRANCH, ENV_CONV_REPO};
use super::dispatch::EnvLookup;
use crate::template::{GitRunner, RealGit, descriptions};
use crate::workspace;
use homes::{copy_dir, followed_commit, skills_pool, unknown};
const SKILLS_DIR: &str = crate::workspace::SKILLS_DIR;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Input {
name: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
struct Output<'a> {
status: &'a str,
path: String,
}
const STATUS_LOADED: &str = "loaded";
const STATUS_ALREADY_LOADED: &str = "already_loaded";
#[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("resolve data root: {0}")]
Root(#[source] crate::harness_root::Error),
#[error("skill name {0:?} is not a single path component (ARCH §3.3)")]
BadName(String),
#[error(
"skill name {0:?} is the archive container, not a skill \
(docs/DESIGN_LEARNING_LOOP.md §5) — an archived body composes nowhere"
)]
Archived(String),
#[error("resolve the followed config commit: {0}")]
Lineage(#[source] io::Error),
#[error("check out workspace skill {name:?} from {commit}: {source}")]
Checkout {
name: String,
commit: String,
#[source]
source: io::Error,
},
#[error("unknown skill {name:?}; workspace skills: {workspace}; install pool: {pool}")]
Unknown {
name: String,
workspace: String,
pool: String,
},
#[error("copy skill {name:?} into worktree: {source}")]
Copy {
name: String,
#[source]
source: io::Error,
},
#[error("write to stdout: {0}")]
Write(#[source] io::Error),
}
pub fn run<R: Read, W: Write>(
stdin: &mut R,
stdout: &mut W,
env: &dyn EnvLookup,
) -> Result<(), Error> {
run_with(stdin, stdout, env, &RealGit::new())
}
pub fn run_with<R: Read, W: Write>(
stdin: &mut R,
stdout: &mut W,
env: &dyn EnvLookup,
git: &dyn GitRunner,
) -> 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 name = input.name;
if !crate::name::is_component(&name) {
return Err(Error::BadName(name));
}
if name == descriptions::ARCHIVED_SUBDIR {
return Err(Error::Archived(name));
}
let repo = require_env(env, ENV_CONV_REPO)?;
let branch = require_env(env, ENV_CONV_BRANCH)?
.into_string()
.map_err(|_| Error::MissingEnv(ENV_CONV_BRANCH))?;
let workspace_dir = PathBuf::from(&repo);
let worktree = workspace::agent_worktree(&workspace_dir, &branch);
let dest = worktree.join(SKILLS_DIR).join(&name);
let rel = format!("{SKILLS_DIR}/{name}");
if dest.is_dir() {
return emit(stdout, STATUS_ALREADY_LOADED, rel);
}
let commit = followed_commit(&workspace_dir, &branch, git)?;
let committed = format!("{commit}:{rel}");
if git.run(&worktree, &["cat-file", "-e", &committed]).is_ok() {
git.run(&worktree, &["checkout", &commit, "--", &rel])
.map_err(|source| Error::Checkout {
name,
commit,
source,
})?;
return emit(stdout, STATUS_LOADED, rel);
}
let src = skills_pool(env)?.join(&name);
if !src.is_dir() {
return Err(unknown(name, &worktree, &commit, &skills_pool(env)?, git));
}
copy_dir(&src, &dest).map_err(|source| Error::Copy {
name: name.clone(),
source,
})?;
emit(stdout, STATUS_LOADED, rel)
}
fn emit<W: Write>(stdout: &mut W, status: &str, path: String) -> Result<(), Error> {
let payload = Output { status, path };
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;
#[cfg(test)]
mod tests_workspace;