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 let system = system.to_lowercase();
315 if system.starts_with("mingw") || system.starts_with("msys") || system.starts_with("cygwin") {
319 return format!("Windows ({raw})");
320 }
321 match manifest.os_names.get(&system) {
322 Some(friendly) => format!("{friendly} ({raw})"),
323 None => raw.to_owned(),
324 }
325}
326
327fn run_probe(manifest: &Manifest, workspace: &Path) -> Result<String, String> {
332 let mut child = Command::new(rhema_agent_tools::exec::bash_executable())
333 .arg("-c")
334 .arg(probe_script(manifest))
335 .current_dir(workspace)
336 .stdin(Stdio::null())
337 .stdout(Stdio::piped())
338 .stderr(Stdio::null())
339 .spawn()
340 .map_err(|err| match err.kind() {
341 std::io::ErrorKind::NotFound => rhema_agent_tools::exec::bash_missing_message(),
342 _ => format!("cannot spawn the probe: {err}"),
343 })?;
344 let deadline = Instant::now() + PROBE_BUDGET;
345 let status = loop {
346 match child.try_wait() {
347 Ok(Some(status)) => break Some(status),
348 Ok(None) if Instant::now() >= deadline => break None,
349 Ok(None) => std::thread::sleep(POLL_STEP),
350 Err(err) => return Err(format!("cannot wait on the probe: {err}")),
351 }
352 };
353 let Some(status) = status else {
354 let _ = child.kill();
355 let _ = child.wait();
356 return Err(format!("no exit within {PROBE_BUDGET:?}"));
357 };
358 if !status.success() {
359 return Err(format!("probe exited: {status}"));
360 }
361 let mut bytes = Vec::new();
362 if let Some(mut stdout) = child.stdout.take() {
363 let _ = (&mut stdout)
364 .take(OUTPUT_CAP as u64)
365 .read_to_end(&mut bytes);
366 }
367 Ok(String::from_utf8_lossy(&bytes).into_owned())
368}
369
370fn utc_now() -> String {
373 let secs = SystemTime::now()
374 .duration_since(UNIX_EPOCH)
375 .unwrap_or_default()
376 .as_secs();
377 let (year, month, day) = civil_from_days((secs / 86_400) as i64);
378 let secs_of_day = secs % 86_400;
379 format!(
380 "{year:04}-{month:02}-{day:02} {:02}:{:02} UTC",
381 secs_of_day / 3_600,
382 secs_of_day % 3_600 / 60
383 )
384}
385
386fn civil_from_days(days: i64) -> (i64, u32, u32) {
389 let z = days + 719_468;
390 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
391 let doe = z - era * 146_097;
392 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
393 let year = yoe + era * 400;
394 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
395 let mp = (5 * doy + 2) / 153;
396 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
397 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
398 (if month <= 2 { year + 1 } else { year }, month, day)
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
410 fn the_baked_manifest_parses_and_every_text_renders() {
411 let manifest = manifest();
412 assert!(!manifest.probe.is_empty(), "an empty inventory is a bug");
413 assert!(!manifest.report.is_empty());
414 assert!(
415 !manifest.facts.is_empty(),
416 "the facts block is written once"
417 );
418 let facts = facts("m", "h");
419 for text in [
420 &manifest.facts,
421 &manifest.report,
422 &manifest.notice,
423 &manifest.unavailable,
424 ] {
425 let context = facts.context(
426 manifest,
427 json!({
428 "os": "macOS (Darwin arm64)", "shell": "/bin/zsh",
429 "workspace": "/ws", "branch": "main",
430 "tools": [{"name": "rg", "state": "present"}], "notes": ["n"],
431 "kind": "k", "reason": "r",
432 }),
433 );
434 let rendered = render_template(text, &context).expect("the text renders");
435 assert!(!rendered.trim().is_empty());
436 }
437 }
438
439 #[test]
447 fn the_host_report_carries_facts_and_presence_without_versions() {
448 let manifest = manifest();
449 let report = host_report(Path::new("."), "glm-5.3", "rhema test");
450 assert!(report.starts_with("## Environment\n"), "{report}");
451 for fact in [
452 "- Date: ",
453 "- Model: glm-5.3",
454 "- Harness: rhema test",
455 "### Machine",
456 "- OS: ",
457 "### Workspace",
458 "- CWD: ",
459 "### Command-line tools",
460 ] {
461 assert!(report.contains(fact), "missing {fact}: {report}");
462 }
463 for entry in &manifest.probe {
464 assert!(
465 report.contains(&format!("- {}: ", entry.executable)),
466 "no line for {}: {report}",
467 entry.executable
468 );
469 }
470 let on_checkout = Command::new("git")
471 .args(["rev-parse", "--is-inside-work-tree"])
472 .output()
473 .is_ok_and(|out| out.status.success());
474 assert_eq!(
475 report.contains("- git branch: "),
476 on_checkout,
477 "branch line must match the checkout: {report}"
478 );
479 assert!(
480 !report.contains("version"),
481 "presence, not versions: {report}"
482 );
483
484 let repo = std::env::temp_dir().join(format!("rhema-env-{}", std::process::id()));
487 let _ = std::fs::remove_dir_all(&repo);
488 std::fs::create_dir_all(&repo).expect("the fixture directory creates");
489 let run = |args: &[&str]| {
490 Command::new("git")
491 .args(["-c", "user.email=test@localhost", "-c", "user.name=test"])
492 .args(args)
493 .current_dir(&repo)
494 .output()
495 .expect("git runs")
496 };
497 run(&["init"]);
498 run(&["commit", "--allow-empty", "-m", "root"]);
499 let branch = String::from_utf8(run(&["branch", "--show-current"]).stdout)
500 .expect("the branch name is utf-8")
501 .trim()
502 .to_owned();
503 let report = host_report(&repo, "glm-5.3", "rhema test");
504 assert!(
505 report.contains(&format!("- git branch: {branch}")),
506 "the checkout's branch reaches the report: {report}"
507 );
508 assert!(
509 !report.contains("version"),
510 "presence, not versions: {report}"
511 );
512
513 run(&["checkout", "--detach"]);
518 let report = host_report(&repo, "glm-5.3", "rhema test");
519 assert!(
520 report.contains("- git branch: detached"),
521 "a detached HEAD says so: {report}"
522 );
523 let _ = std::fs::remove_dir_all(&repo);
524 }
525
526 #[test]
529 fn the_os_line_maps_friendly_names_beside_the_raw() {
530 let manifest = manifest();
531 assert_eq!(os_label(manifest, "Darwin arm64"), "macOS (Darwin arm64)");
532 assert_eq!(os_label(manifest, "SunOS sun4u"), "SunOS sun4u");
533 }
534
535 #[test]
538 fn advice_follows_presence_not_the_tool_name() {
539 let manifest = manifest();
540 let facts = facts("m", "h");
541 let with = render_report(
542 manifest,
543 Path::new("/ws"),
544 &facts,
545 "Darwin arm64\nshell: /bin/zsh\nrg: present\nfd: absent\ngit branch: main\n",
546 );
547 assert!(with.contains("Notes:"), "{with}");
548 assert!(with.contains("Use `rg`"), "{with}");
549 let without = render_report(
550 manifest,
551 Path::new("/ws"),
552 &facts,
553 "Darwin arm64\nshell: /bin/zsh\nrg: absent\nfd: absent\n",
554 );
555 assert!(!without.contains("Notes:"), "{without}");
556 assert!(without.contains("- rg: absent"), "{without}");
557 }
558
559 #[test]
562 fn the_notice_names_the_kind_workspace_and_no_host_facts() {
563 let notice = remote_notice("docker", "/sandbox/ws", "glm-5.3", "rhema test");
564 assert!(notice.contains("docker sandbox"), "{notice}");
565 assert!(notice.contains("- Workspace: /sandbox/ws"), "{notice}");
566 assert!(notice.contains("- Model: glm-5.3"), "{notice}");
567 assert!(notice.contains("- Harness: rhema test"), "{notice}");
568 assert!(!notice.contains("- OS: "), "{notice}");
569 }
570
571 #[test]
574 fn unsafe_executables_never_reach_the_script() {
575 let mut manifest = manifest().clone();
576 manifest.probe.push(Probe {
577 executable: "x; rm -rf /".into(),
578 advice: None,
579 });
580 let script = probe_script(&manifest);
581 assert!(!script.contains("rm -rf"), "{script}");
582 }
583
584 #[test]
587 fn a_missing_workspace_renders_the_unavailable_text() {
588 let report = host_report(Path::new("/definitely/not/here"), "m", "h");
589 assert!(report.contains("did not answer"), "{report}");
590 assert!(report.contains("- Model: m"), "{report}");
591 }
592
593 #[test]
595 fn the_stamp_is_civil_utc() {
596 assert_eq!(civil_from_days(0), (1970, 1, 1));
597 assert_eq!(civil_from_days(19_723), (2024, 1, 1));
598 }
599}