use std::collections::BTreeMap;
use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::OnceLock;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use rhema_agent_tools::render_template;
use serde_json::json;
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
pub struct Probe {
pub executable: String,
#[serde(default)]
pub advice: Option<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
pub struct Manifest {
#[serde(default)]
pub os_names: BTreeMap<String, String>,
pub probe: Vec<Probe>,
pub facts: String,
pub report: String,
pub notice: String,
pub unavailable: String,
}
pub fn manifest() -> &'static Manifest {
static MANIFEST: OnceLock<Manifest> = OnceLock::new();
MANIFEST.get_or_init(|| {
serde_yaml::from_str(include_str!("../environment.yaml")).unwrap_or_else(|_| Manifest {
os_names: BTreeMap::new(),
probe: Vec::new(),
facts: String::new(),
report: String::new(),
notice: String::new(),
unavailable: String::new(),
})
})
}
fn shell_safe(executable: &str) -> bool {
!executable.is_empty()
&& executable
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
}
const PROBE_BUDGET: Duration = Duration::from_secs(5);
const POLL_STEP: Duration = Duration::from_millis(10);
const OUTPUT_CAP: usize = 4 * 1024;
fn probe_script(manifest: &Manifest) -> String {
let tools: Vec<&str> = manifest
.probe
.iter()
.filter(|entry| shell_safe(&entry.executable))
.map(|entry| entry.executable.as_str())
.collect();
format!(
r#"
uname -sm
printf 'shell: %s\n' "${{SHELL:-unset}}"
for t in {tools}; do
if command -v "$t" >/dev/null 2>&1; then
printf '%s: present\n' "$t"
else
printf '%s: absent\n' "$t"
fi
done
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
printf 'git branch: %s\n' "$(git branch --show-current 2>/dev/null || printf detached)"
fi
"#,
tools = tools.join(" ")
)
}
pub fn host_report(workspace: &Path, model: &str, harness: &str) -> String {
let facts = facts(model, harness);
match run_probe(manifest(), workspace) {
Ok(output) => render_report(manifest(), workspace, &facts, &output),
Err(reason) => unavailable(&facts, &reason),
}
}
pub fn remote_notice(kind: &str, workspace: &str, model: &str, harness: &str) -> String {
let manifest = manifest();
let facts = facts(model, harness);
let rendered = render_text(
&manifest.notice,
&facts.context(manifest, json!({ "kind": kind, "workspace": workspace })),
);
if rendered.is_empty() {
fallback_report(&facts)
} else {
rendered
}
}
struct Facts {
date: String,
model: String,
harness: String,
}
impl Facts {
fn context(&self, manifest: &Manifest, extra: serde_json::Value) -> serde_json::Value {
let serde_json::Value::Object(mut map) = extra else {
return serde_json::Value::Null;
};
map.insert("date".into(), json!(self.date));
map.insert("model".into(), json!(self.model));
map.insert("harness".into(), json!(self.harness));
let facts = render_template(
&manifest.facts,
&json!({ "date": self.date, "model": self.model, "harness": self.harness }),
)
.unwrap_or_default();
map.insert("facts".into(), json!(facts));
serde_json::Value::Object(map)
}
}
fn facts(model: &str, harness: &str) -> Facts {
Facts {
date: utc_now(),
model: model.to_owned(),
harness: harness.to_owned(),
}
}
fn render_text(text: &str, context: &serde_json::Value) -> String {
if text.is_empty() {
return String::new();
}
render_template(text, context).unwrap_or_default()
}
fn render_report(manifest: &Manifest, workspace: &Path, facts: &Facts, output: &str) -> String {
let mut lines = output.lines().filter(|line| !line.trim().is_empty());
let os = os_label(manifest, lines.next().unwrap_or("unknown"));
let shell = lines
.next()
.and_then(|line| line.strip_prefix("shell: "))
.unwrap_or("unknown")
.to_owned();
let rest: Vec<&str> = lines.collect();
let branch = rest
.iter()
.find_map(|l| l.strip_prefix("git branch: "))
.map(str::to_owned);
let tools: Vec<serde_json::Value> = manifest
.probe
.iter()
.map(|entry| {
let present = rest
.iter()
.any(|l| *l == format!("{}: present", entry.executable));
json!({ "name": entry.executable, "state": if present { "present" } else { "absent" } })
})
.collect();
let notes: Vec<String> = manifest
.probe
.iter()
.filter(|entry| {
entry.advice.is_some()
&& rest
.iter()
.any(|l| *l == format!("{}: present", entry.executable))
})
.filter_map(|entry| entry.advice.clone())
.collect();
let context = facts.context(
manifest,
json!({
"os": os,
"shell": shell,
"workspace": workspace.display().to_string(),
"branch": branch,
"tools": tools,
"notes": notes,
}),
);
let rendered = render_text(&manifest.report, &context);
if rendered.is_empty() {
fallback_report(facts)
} else {
rendered
}
}
fn unavailable(facts: &Facts, reason: &str) -> String {
let manifest = manifest();
let rendered = render_text(
&manifest.unavailable,
&facts.context(manifest, json!({ "reason": reason })),
);
if rendered.is_empty() {
fallback_report(facts)
} else {
rendered
}
}
fn fallback_report(facts: &Facts) -> String {
format!(
"## Environment\n\n\
The report's data did not parse; ask the shell (`uname -sm`,\n\
`command -v <tool>`) when a fact matters.\n\n\
- Date: {}\n- Model: {}\n- Harness: {}\n",
facts.date, facts.model, facts.harness
)
}
fn os_label(manifest: &Manifest, raw: &str) -> String {
let Some((system, _rest)) = raw.split_once(' ') else {
return raw.to_owned();
};
match manifest.os_names.get(&system.to_lowercase()) {
Some(friendly) => format!("{friendly} ({raw})"),
None => raw.to_owned(),
}
}
fn run_probe(manifest: &Manifest, workspace: &Path) -> Result<String, String> {
let mut child = Command::new("bash")
.arg("-c")
.arg(probe_script(manifest))
.current_dir(workspace)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|err| format!("cannot spawn the probe: {err}"))?;
let deadline = Instant::now() + PROBE_BUDGET;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) if Instant::now() >= deadline => break None,
Ok(None) => std::thread::sleep(POLL_STEP),
Err(err) => return Err(format!("cannot wait on the probe: {err}")),
}
};
let Some(status) = status else {
let _ = child.kill();
let _ = child.wait();
return Err(format!("no exit within {PROBE_BUDGET:?}"));
};
if !status.success() {
return Err(format!("probe exited: {status}"));
}
let mut bytes = Vec::new();
if let Some(mut stdout) = child.stdout.take() {
let _ = (&mut stdout)
.take(OUTPUT_CAP as u64)
.read_to_end(&mut bytes);
}
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
fn utc_now() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let (year, month, day) = civil_from_days((secs / 86_400) as i64);
let secs_of_day = secs % 86_400;
format!(
"{year:04}-{month:02}-{day:02} {:02}:{:02} UTC",
secs_of_day / 3_600,
secs_of_day % 3_600 / 60
)
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if month <= 2 { year + 1 } else { year }, month, day)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_baked_manifest_parses_and_every_text_renders() {
let manifest = manifest();
assert!(!manifest.probe.is_empty(), "an empty inventory is a bug");
assert!(!manifest.report.is_empty());
assert!(
!manifest.facts.is_empty(),
"the facts block is written once"
);
let facts = facts("m", "h");
for text in [
&manifest.facts,
&manifest.report,
&manifest.notice,
&manifest.unavailable,
] {
let context = facts.context(
manifest,
json!({
"os": "macOS (Darwin arm64)", "shell": "/bin/zsh",
"workspace": "/ws", "branch": "main",
"tools": [{"name": "rg", "state": "present"}], "notes": ["n"],
"kind": "k", "reason": "r",
}),
);
let rendered = render_template(text, &context).expect("the text renders");
assert!(!rendered.trim().is_empty());
}
}
#[test]
fn the_host_report_carries_facts_and_presence_without_versions() {
let manifest = manifest();
let report = host_report(Path::new("."), "glm-5.3", "rhema test");
assert!(report.starts_with("## Environment\n"), "{report}");
for fact in [
"- Date: ",
"- Model: glm-5.3",
"- Harness: rhema test",
"### Machine",
"- OS: ",
"### Workspace",
"- CWD: ",
"### Command-line tools",
] {
assert!(report.contains(fact), "missing {fact}: {report}");
}
for entry in &manifest.probe {
assert!(
report.contains(&format!("- {}: ", entry.executable)),
"no line for {}: {report}",
entry.executable
);
}
assert!(report.contains("- git branch: "), "{report}");
assert!(
!report.contains("version"),
"presence, not versions: {report}"
);
}
#[test]
fn the_os_line_maps_friendly_names_beside_the_raw() {
let manifest = manifest();
assert_eq!(os_label(manifest, "Darwin arm64"), "macOS (Darwin arm64)");
assert_eq!(os_label(manifest, "SunOS sun4u"), "SunOS sun4u");
}
#[test]
fn advice_follows_presence_not_the_tool_name() {
let manifest = manifest();
let facts = facts("m", "h");
let with = render_report(
manifest,
Path::new("/ws"),
&facts,
"Darwin arm64\nshell: /bin/zsh\nrg: present\nfd: absent\ngit branch: main\n",
);
assert!(with.contains("Notes:"), "{with}");
assert!(with.contains("Use `rg`"), "{with}");
let without = render_report(
manifest,
Path::new("/ws"),
&facts,
"Darwin arm64\nshell: /bin/zsh\nrg: absent\nfd: absent\n",
);
assert!(!without.contains("Notes:"), "{without}");
assert!(without.contains("- rg: absent"), "{without}");
}
#[test]
fn the_notice_names_the_kind_workspace_and_no_host_facts() {
let notice = remote_notice("docker", "/sandbox/ws", "glm-5.3", "rhema test");
assert!(notice.contains("docker sandbox"), "{notice}");
assert!(notice.contains("- Workspace: /sandbox/ws"), "{notice}");
assert!(notice.contains("- Model: glm-5.3"), "{notice}");
assert!(notice.contains("- Harness: rhema test"), "{notice}");
assert!(!notice.contains("- OS: "), "{notice}");
}
#[test]
fn unsafe_executables_never_reach_the_script() {
let mut manifest = manifest().clone();
manifest.probe.push(Probe {
executable: "x; rm -rf /".into(),
advice: None,
});
let script = probe_script(&manifest);
assert!(!script.contains("rm -rf"), "{script}");
}
#[test]
fn a_missing_workspace_renders_the_unavailable_text() {
let report = host_report(Path::new("/definitely/not/here"), "m", "h");
assert!(report.contains("did not answer"), "{report}");
assert!(report.contains("- Model: m"), "{report}");
}
#[test]
fn the_stamp_is_civil_utc() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
}
}