use std::{collections::BTreeMap, ffi::OsString, path::Path};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::{
domain::{AppError, ErrorKind, InternalGitProfile},
git::GitRunner,
};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConfigSnapshot {
pub hash: String,
pub source_oid: String,
pub checks: Vec<CheckDefinition>,
pub raw: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckDefinition {
pub name: String,
pub command: Vec<String>,
#[serde(default)]
pub required: bool,
#[serde(default = "default_check_timeout_seconds")]
pub timeout_seconds: u64,
#[serde(default = "default_check_output_limit_bytes")]
pub output_limit_bytes: usize,
}
fn default_check_timeout_seconds() -> u64 {
1800
}
fn default_check_output_limit_bytes() -> usize {
8 * 1024 * 1024
}
const MAX_CHECK_TIMEOUT_SECONDS: u64 = 7 * 24 * 60 * 60;
const MAX_CHECK_OUTPUT_LIMIT_BYTES: u64 = 64 * 1024 * 1024;
pub fn snapshot(git: &GitRunner, root: &Path, base_oid: &str) -> Result<ConfigSnapshot, AppError> {
let raw = match git.run(
root,
InternalGitProfile::Discovery,
&[
OsString::from("show"),
OsString::from(format!("{base_oid}:.agentree.toml")),
],
) {
Ok(output) => String::from_utf8(output.stdout).map_err(|_| {
AppError::diagnostic(
"AGT-0401",
"project config is not UTF-8",
ErrorKind::Unsupported,
)
})?,
Err(_) => String::new(),
};
let parsed: toml::Value = if raw.trim().is_empty() {
toml::Value::Table(Default::default())
} else {
raw.parse()?
};
let json = serde_json::to_value(&parsed)
.map_err(|error| AppError::diagnostic("AGT-0402", error.to_string(), ErrorKind::Usage))?;
let mut table = match json {
serde_json::Value::Object(map) => map.into_iter().collect::<BTreeMap<_, _>>(),
_ => BTreeMap::new(),
};
let checks = parse_checks(table.get("checks"))?;
let canonical = serde_json::to_vec(&table)?;
let mut hasher = Sha256::new();
hasher.update(canonical);
let hash = format!("sha256:{:x}", hasher.finalize());
Ok(ConfigSnapshot {
hash,
source_oid: base_oid.to_owned(),
checks,
raw: std::mem::take(&mut table),
})
}
fn parse_checks(value: Option<&serde_json::Value>) -> Result<Vec<CheckDefinition>, AppError> {
let Some(value) = value else {
return Ok(Vec::new());
};
let Some(items) = value.as_array() else {
return Err(AppError::diagnostic(
"AGT-0403",
"checks must be an array",
ErrorKind::Usage,
));
};
items
.iter()
.map(|item| {
let object = item.as_object().ok_or_else(|| {
AppError::diagnostic("AGT-0404", "each check must be a table", ErrorKind::Usage)
})?;
let name = object
.get("name")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
AppError::diagnostic("AGT-0405", "check.name is required", ErrorKind::Usage)
})?
.to_owned();
let command = object
.get("command")
.or_else(|| object.get("argv"))
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
AppError::diagnostic(
"AGT-0406",
"check.command or check.argv must be an argv array",
ErrorKind::Usage,
)
})?
.iter()
.map(|part| {
part.as_str().map(str::to_owned).ok_or_else(|| {
AppError::diagnostic(
"AGT-0407",
"check command arguments must be strings",
ErrorKind::Usage,
)
})
})
.collect::<Result<Vec<_>, _>>()?;
if command.is_empty() {
return Err(AppError::diagnostic(
"AGT-0408",
"check command cannot be empty",
ErrorKind::Usage,
));
}
let timeout_seconds = match object.get("timeout_seconds") {
Some(value) => value.as_u64().ok_or_else(|| {
AppError::diagnostic(
"AGT-0409",
"check.timeout_seconds must be an integer",
ErrorKind::Usage,
)
})?,
None => default_check_timeout_seconds(),
};
if timeout_seconds == 0 || timeout_seconds > MAX_CHECK_TIMEOUT_SECONDS {
return Err(AppError::diagnostic(
"AGT-0409",
format!("check.timeout_seconds must be between 1 and {MAX_CHECK_TIMEOUT_SECONDS}"),
ErrorKind::Usage,
));
}
let output_limit_bytes = match object.get("output_limit_bytes") {
Some(value) => value.as_u64().ok_or_else(|| {
AppError::diagnostic(
"AGT-0410",
"check.output_limit_bytes must be an integer",
ErrorKind::Usage,
)
})?,
None => default_check_output_limit_bytes() as u64,
};
if output_limit_bytes == 0 || output_limit_bytes > MAX_CHECK_OUTPUT_LIMIT_BYTES {
return Err(AppError::diagnostic(
"AGT-0410",
format!("check.output_limit_bytes must be between 1 and {MAX_CHECK_OUTPUT_LIMIT_BYTES}"),
ErrorKind::Usage,
));
}
Ok(CheckDefinition {
name,
command,
required: object
.get("required")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
timeout_seconds,
output_limit_bytes: usize::try_from(output_limit_bytes).map_err(|_| {
AppError::diagnostic(
"AGT-0411",
"check.output_limit_bytes is too large for this platform",
ErrorKind::Usage,
)
})?,
})
})
.collect()
}
pub fn scaffold() -> &'static str {
"# Agentree project configuration\n# Commands are argv arrays; no shell interpolation is performed.\nchecks = [\n # { name = \"unit\", command = [\"cargo\", \"test\"], required = true },\n]\n"
}