use serde::{Deserialize, Serialize};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use thiserror::Error;
use super::super::{ENV_CONV_BRANCH, ENV_CONV_REPO};
use super::dispatch::EnvLookup;
use super::harness;
use crate::template::authoring::{self, Origin, Pass};
use crate::template::{GitRunner, RealGit};
const SUBJECT_BYTES: usize = 64;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Input {
fact: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
struct Output {
status: &'static str,
proposal: String,
accept: 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("`fact` is empty: say the fact you want a later conversation to know")]
Blank,
#[error("resolve the harness root: {0}")]
Root(#[source] crate::harness_root::Error),
#[error("resolve the followed config commit: {0}")]
Lineage(#[source] io::Error),
#[error(transparent)]
Stage(#[from] authoring::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 fact = input.fact.trim().to_owned();
if fact.is_empty() {
return Err(Error::Blank);
}
let repo = env
.get(ENV_CONV_REPO)
.ok_or(Error::MissingEnv(ENV_CONV_REPO))?;
let agent = env
.get(ENV_CONV_BRANCH)
.and_then(|v| v.into_string().ok())
.ok_or(Error::MissingEnv(ENV_CONV_BRANCH))?;
let workspace = PathBuf::from(repo);
let roots = harness::roots(env).map_err(Error::Root)?;
let parent = harness::followed_commit(&workspace, &agent, git).map_err(Error::Lineage)?;
let message = commit_message(&fact, &agent);
let pass = authoring::author(
&workspace,
&roots.data,
&agent,
Origin::Proposal {
parent: &parent,
message: &message,
},
|checkout| append(checkout, &fact),
git,
)?;
emit(stdout, &agent, &pass)
}
fn append(checkout: &Path, fact: &str) -> io::Result<()> {
let path = checkout.join(crate::facts::FILE);
let standing = std::fs::read_to_string(&path).unwrap_or_default();
let trimmed = standing.trim_end();
if trimmed.split("\n\n").any(|para| para.trim() == fact) {
return Ok(());
}
let body = if trimmed.is_empty() {
format!("{fact}\n")
} else {
format!("{trimmed}\n\n{fact}\n")
};
std::fs::write(path, body)
}
fn commit_message(fact: &str, agent: &str) -> String {
let head = fact.lines().next().unwrap_or_default();
let subject = match head.char_indices().nth(SUBJECT_BYTES) {
Some((cut, _)) => format!("{}…", &head[..cut]),
None => head.to_owned(),
};
format!("facts: {subject}\n\nProposed by {agent} through the `remember` tool.\n")
}
fn emit<W: Write>(stdout: &mut W, agent: &str, pass: &Pass) -> Result<(), Error> {
let payload = Output {
status: match pass {
Pass::Landed => "proposed",
Pass::Declined { .. } => "already_recorded",
},
proposal: agent.to_owned(),
accept: format!("litany proposal <workspace> {agent} --accept"),
};
let bytes = serde_json::to_vec(&payload).expect("Output is always serializable");
stdout.write_all(&bytes).map_err(Error::Write)
}
#[cfg(test)]
mod tests;