1use std::collections::BTreeMap;
29use std::io::Read;
30use std::path::Path;
31use std::process::{Command, Stdio};
32use std::sync::OnceLock;
33use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
34
35use rhema_agent_tools::render_template;
36use serde_json::json;
37
38#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
40pub struct Probe {
41 pub executable: String,
44 #[serde(default)]
47 pub advice: Option<String>,
48}
49
50#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
53pub struct Manifest {
54 #[serde(default)]
57 pub os_names: BTreeMap<String, String>,
58 pub probe: Vec<Probe>,
60 pub facts: String,
63 pub report: String,
65 pub notice: String,
67 pub unavailable: String,
69}
70
71pub fn manifest() -> &'static Manifest {
75 static MANIFEST: OnceLock<Manifest> = OnceLock::new();
76 MANIFEST.get_or_init(|| {
77 serde_yaml::from_str(include_str!("../environment.yaml")).unwrap_or_else(|_| Manifest {
78 os_names: BTreeMap::new(),
79 probe: Vec::new(),
80 facts: String::new(),
81 report: String::new(),
82 notice: String::new(),
83 unavailable: String::new(),
84 })
85 })
86}
87
88fn shell_safe(executable: &str) -> bool {
92 !executable.is_empty()
93 && executable
94 .chars()
95 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
96}
97
98const PROBE_BUDGET: Duration = Duration::from_secs(5);
101
102const POLL_STEP: Duration = Duration::from_millis(10);
104
105const OUTPUT_CAP: usize = 4 * 1024;
109
110fn probe_script(manifest: &Manifest) -> String {
116 let tools: Vec<&str> = manifest
117 .probe
118 .iter()
119 .filter(|entry| shell_safe(&entry.executable))
120 .map(|entry| entry.executable.as_str())
121 .collect();
122 format!(
123 r#"
124uname -sm
125printf 'shell: %s\n' "${{SHELL:-unset}}"
126for t in {tools}; do
127 if command -v "$t" >/dev/null 2>&1; then
128 printf '%s: present\n' "$t"
129 else
130 printf '%s: absent\n' "$t"
131 fi
132done
133if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
134 # `git branch --show-current` answers empty, and exits 0, on a
135 # detached HEAD (a PR checkout, a tag, a bisect), so the name is
136 # captured first: a blank one means detached, not "no branch line".
137 b=$(git branch --show-current 2>/dev/null)
138 [ -n "$b" ] || b=detached
139 printf 'git branch: %s\n' "$b"
140fi
141"#,
142 tools = tools.join(" ")
143 )
144}
145
146pub fn host_report(workspace: &Path, model: &str, harness: &str) -> String {
151 let facts = facts(model, harness);
152 match run_probe(manifest(), workspace) {
153 Ok(output) => render_report(manifest(), workspace, &facts, &output),
154 Err(reason) => unavailable(&facts, &reason),
155 }
156}
157
158pub fn remote_notice(kind: &str, workspace: &str, model: &str, harness: &str) -> String {
164 let manifest = manifest();
165 let facts = facts(model, harness);
166 let rendered = render_text(
167 &manifest.notice,
168 &facts.context(manifest, json!({ "kind": kind, "workspace": workspace })),
169 );
170 if rendered.is_empty() {
171 fallback_report(&facts)
172 } else {
173 rendered
174 }
175}
176
177struct Facts {
179 date: String,
181 model: String,
183 harness: String,
185}
186
187impl Facts {
188 fn context(&self, manifest: &Manifest, extra: serde_json::Value) -> serde_json::Value {
192 let serde_json::Value::Object(mut map) = extra else {
193 return serde_json::Value::Null;
194 };
195 map.insert("date".into(), json!(self.date));
196 map.insert("model".into(), json!(self.model));
197 map.insert("harness".into(), json!(self.harness));
198 let facts = render_template(
199 &manifest.facts,
200 &json!({ "date": self.date, "model": self.model, "harness": self.harness }),
201 )
202 .unwrap_or_default();
203 map.insert("facts".into(), json!(facts));
204 serde_json::Value::Object(map)
205 }
206}
207
208fn facts(model: &str, harness: &str) -> Facts {
209 Facts {
210 date: utc_now(),
211 model: model.to_owned(),
212 harness: harness.to_owned(),
213 }
214}
215
216fn render_text(text: &str, context: &serde_json::Value) -> String {
219 if text.is_empty() {
220 return String::new();
221 }
222 render_template(text, context).unwrap_or_default()
223}
224
225fn render_report(manifest: &Manifest, workspace: &Path, facts: &Facts, output: &str) -> String {
229 let mut lines = output.lines().filter(|line| !line.trim().is_empty());
230 let os = os_label(manifest, lines.next().unwrap_or("unknown"));
231 let shell = lines
232 .next()
233 .and_then(|line| line.strip_prefix("shell: "))
234 .unwrap_or("unknown")
235 .to_owned();
236 let rest: Vec<&str> = lines.collect();
237 let branch = rest
238 .iter()
239 .find_map(|l| l.strip_prefix("git branch: "))
240 .map(str::to_owned);
241 let tools: Vec<serde_json::Value> = manifest
242 .probe
243 .iter()
244 .map(|entry| {
245 let present = rest
246 .iter()
247 .any(|l| *l == format!("{}: present", entry.executable));
248 json!({ "name": entry.executable, "state": if present { "present" } else { "absent" } })
249 })
250 .collect();
251 let notes: Vec<String> = manifest
252 .probe
253 .iter()
254 .filter(|entry| {
255 entry.advice.is_some()
256 && rest
257 .iter()
258 .any(|l| *l == format!("{}: present", entry.executable))
259 })
260 .filter_map(|entry| entry.advice.clone())
261 .collect();
262 let context = facts.context(
263 manifest,
264 json!({
265 "os": os,
266 "shell": shell,
267 "workspace": workspace.display().to_string(),
268 "branch": branch,
269 "tools": tools,
270 "notes": notes,
271 }),
272 );
273 let rendered = render_text(&manifest.report, &context);
274 if rendered.is_empty() {
275 fallback_report(facts)
276 } else {
277 rendered
278 }
279}
280
281fn unavailable(facts: &Facts, reason: &str) -> String {
283 let manifest = manifest();
284 let rendered = render_text(
285 &manifest.unavailable,
286 &facts.context(manifest, json!({ "reason": reason })),
287 );
288 if rendered.is_empty() {
289 fallback_report(facts)
290 } else {
291 rendered
292 }
293}
294
295fn fallback_report(facts: &Facts) -> String {
298 format!(
299 "## Environment\n\n\
300 The report's data did not parse; ask the shell (`uname -sm`,\n\
301 `command -v <tool>`) when a fact matters.\n\n\
302 - Date: {}\n- Model: {}\n- Harness: {}\n",
303 facts.date, facts.model, facts.harness
304 )
305}
306
307fn os_label(manifest: &Manifest, raw: &str) -> String {
311 let Some((system, _rest)) = raw.split_once(' ') else {
312 return raw.to_owned();
313 };
314 match manifest.os_names.get(&system.to_lowercase()) {
315 Some(friendly) => format!("{friendly} ({raw})"),
316 None => raw.to_owned(),
317 }
318}
319
320fn run_probe(manifest: &Manifest, workspace: &Path) -> Result<String, String> {
325 let mut child = Command::new("bash")
326 .arg("-c")
327 .arg(probe_script(manifest))
328 .current_dir(workspace)
329 .stdin(Stdio::null())
330 .stdout(Stdio::piped())
331 .stderr(Stdio::null())
332 .spawn()
333 .map_err(|err| format!("cannot spawn the probe: {err}"))?;
334 let deadline = Instant::now() + PROBE_BUDGET;
335 let status = loop {
336 match child.try_wait() {
337 Ok(Some(status)) => break Some(status),
338 Ok(None) if Instant::now() >= deadline => break None,
339 Ok(None) => std::thread::sleep(POLL_STEP),
340 Err(err) => return Err(format!("cannot wait on the probe: {err}")),
341 }
342 };
343 let Some(status) = status else {
344 let _ = child.kill();
345 let _ = child.wait();
346 return Err(format!("no exit within {PROBE_BUDGET:?}"));
347 };
348 if !status.success() {
349 return Err(format!("probe exited: {status}"));
350 }
351 let mut bytes = Vec::new();
352 if let Some(mut stdout) = child.stdout.take() {
353 let _ = (&mut stdout)
354 .take(OUTPUT_CAP as u64)
355 .read_to_end(&mut bytes);
356 }
357 Ok(String::from_utf8_lossy(&bytes).into_owned())
358}
359
360fn utc_now() -> String {
363 let secs = SystemTime::now()
364 .duration_since(UNIX_EPOCH)
365 .unwrap_or_default()
366 .as_secs();
367 let (year, month, day) = civil_from_days((secs / 86_400) as i64);
368 let secs_of_day = secs % 86_400;
369 format!(
370 "{year:04}-{month:02}-{day:02} {:02}:{:02} UTC",
371 secs_of_day / 3_600,
372 secs_of_day % 3_600 / 60
373 )
374}
375
376fn civil_from_days(days: i64) -> (i64, u32, u32) {
379 let z = days + 719_468;
380 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
381 let doe = z - era * 146_097;
382 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
383 let year = yoe + era * 400;
384 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
385 let mp = (5 * doy + 2) / 153;
386 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
387 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
388 (if month <= 2 { year + 1 } else { year }, month, day)
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
400 fn the_baked_manifest_parses_and_every_text_renders() {
401 let manifest = manifest();
402 assert!(!manifest.probe.is_empty(), "an empty inventory is a bug");
403 assert!(!manifest.report.is_empty());
404 assert!(
405 !manifest.facts.is_empty(),
406 "the facts block is written once"
407 );
408 let facts = facts("m", "h");
409 for text in [
410 &manifest.facts,
411 &manifest.report,
412 &manifest.notice,
413 &manifest.unavailable,
414 ] {
415 let context = facts.context(
416 manifest,
417 json!({
418 "os": "macOS (Darwin arm64)", "shell": "/bin/zsh",
419 "workspace": "/ws", "branch": "main",
420 "tools": [{"name": "rg", "state": "present"}], "notes": ["n"],
421 "kind": "k", "reason": "r",
422 }),
423 );
424 let rendered = render_template(text, &context).expect("the text renders");
425 assert!(!rendered.trim().is_empty());
426 }
427 }
428
429 #[test]
437 fn the_host_report_carries_facts_and_presence_without_versions() {
438 let manifest = manifest();
439 let report = host_report(Path::new("."), "glm-5.3", "rhema test");
440 assert!(report.starts_with("## Environment\n"), "{report}");
441 for fact in [
442 "- Date: ",
443 "- Model: glm-5.3",
444 "- Harness: rhema test",
445 "### Machine",
446 "- OS: ",
447 "### Workspace",
448 "- CWD: ",
449 "### Command-line tools",
450 ] {
451 assert!(report.contains(fact), "missing {fact}: {report}");
452 }
453 for entry in &manifest.probe {
454 assert!(
455 report.contains(&format!("- {}: ", entry.executable)),
456 "no line for {}: {report}",
457 entry.executable
458 );
459 }
460 let on_checkout = Command::new("git")
461 .args(["rev-parse", "--is-inside-work-tree"])
462 .output()
463 .is_ok_and(|out| out.status.success());
464 assert_eq!(
465 report.contains("- git branch: "),
466 on_checkout,
467 "branch line must match the checkout: {report}"
468 );
469 assert!(
470 !report.contains("version"),
471 "presence, not versions: {report}"
472 );
473
474 let repo = std::env::temp_dir().join(format!("rhema-env-{}", std::process::id()));
477 let _ = std::fs::remove_dir_all(&repo);
478 std::fs::create_dir_all(&repo).expect("the fixture directory creates");
479 let run = |args: &[&str]| {
480 Command::new("git")
481 .args(["-c", "user.email=test@localhost", "-c", "user.name=test"])
482 .args(args)
483 .current_dir(&repo)
484 .output()
485 .expect("git runs")
486 };
487 run(&["init"]);
488 run(&["commit", "--allow-empty", "-m", "root"]);
489 let branch = String::from_utf8(run(&["branch", "--show-current"]).stdout)
490 .expect("the branch name is utf-8")
491 .trim()
492 .to_owned();
493 let report = host_report(&repo, "glm-5.3", "rhema test");
494 assert!(
495 report.contains(&format!("- git branch: {branch}")),
496 "the checkout's branch reaches the report: {report}"
497 );
498 assert!(
499 !report.contains("version"),
500 "presence, not versions: {report}"
501 );
502
503 run(&["checkout", "--detach"]);
508 let report = host_report(&repo, "glm-5.3", "rhema test");
509 assert!(
510 report.contains("- git branch: detached"),
511 "a detached HEAD says so: {report}"
512 );
513 let _ = std::fs::remove_dir_all(&repo);
514 }
515
516 #[test]
519 fn the_os_line_maps_friendly_names_beside_the_raw() {
520 let manifest = manifest();
521 assert_eq!(os_label(manifest, "Darwin arm64"), "macOS (Darwin arm64)");
522 assert_eq!(os_label(manifest, "SunOS sun4u"), "SunOS sun4u");
523 }
524
525 #[test]
528 fn advice_follows_presence_not_the_tool_name() {
529 let manifest = manifest();
530 let facts = facts("m", "h");
531 let with = render_report(
532 manifest,
533 Path::new("/ws"),
534 &facts,
535 "Darwin arm64\nshell: /bin/zsh\nrg: present\nfd: absent\ngit branch: main\n",
536 );
537 assert!(with.contains("Notes:"), "{with}");
538 assert!(with.contains("Use `rg`"), "{with}");
539 let without = render_report(
540 manifest,
541 Path::new("/ws"),
542 &facts,
543 "Darwin arm64\nshell: /bin/zsh\nrg: absent\nfd: absent\n",
544 );
545 assert!(!without.contains("Notes:"), "{without}");
546 assert!(without.contains("- rg: absent"), "{without}");
547 }
548
549 #[test]
552 fn the_notice_names_the_kind_workspace_and_no_host_facts() {
553 let notice = remote_notice("docker", "/sandbox/ws", "glm-5.3", "rhema test");
554 assert!(notice.contains("docker sandbox"), "{notice}");
555 assert!(notice.contains("- Workspace: /sandbox/ws"), "{notice}");
556 assert!(notice.contains("- Model: glm-5.3"), "{notice}");
557 assert!(notice.contains("- Harness: rhema test"), "{notice}");
558 assert!(!notice.contains("- OS: "), "{notice}");
559 }
560
561 #[test]
564 fn unsafe_executables_never_reach_the_script() {
565 let mut manifest = manifest().clone();
566 manifest.probe.push(Probe {
567 executable: "x; rm -rf /".into(),
568 advice: None,
569 });
570 let script = probe_script(&manifest);
571 assert!(!script.contains("rm -rf"), "{script}");
572 }
573
574 #[test]
577 fn a_missing_workspace_renders_the_unavailable_text() {
578 let report = host_report(Path::new("/definitely/not/here"), "m", "h");
579 assert!(report.contains("did not answer"), "{report}");
580 assert!(report.contains("- Model: m"), "{report}");
581 }
582
583 #[test]
585 fn the_stamp_is_civil_utc() {
586 assert_eq!(civil_from_days(0), (1970, 1, 1));
587 assert_eq!(civil_from_days(19_723), (2024, 1, 1));
588 }
589}