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 {
115 let tools: Vec<&str> = manifest
116 .probe
117 .iter()
118 .filter(|entry| shell_safe(&entry.executable))
119 .map(|entry| entry.executable.as_str())
120 .collect();
121 format!(
122 r#"
123uname -sm
124printf 'shell: %s\n' "${{SHELL:-unset}}"
125for t in {tools}; do
126 if command -v "$t" >/dev/null 2>&1; then
127 printf '%s: present\n' "$t"
128 else
129 printf '%s: absent\n' "$t"
130 fi
131done
132if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
133 printf 'git branch: %s\n' "$(git branch --show-current 2>/dev/null || printf detached)"
134fi
135"#,
136 tools = tools.join(" ")
137 )
138}
139
140pub fn host_report(workspace: &Path, model: &str, harness: &str) -> String {
145 let facts = facts(model, harness);
146 match run_probe(manifest(), workspace) {
147 Ok(output) => render_report(manifest(), workspace, &facts, &output),
148 Err(reason) => unavailable(&facts, &reason),
149 }
150}
151
152pub fn remote_notice(kind: &str, workspace: &str, model: &str, harness: &str) -> String {
158 let manifest = manifest();
159 let facts = facts(model, harness);
160 let rendered = render_text(
161 &manifest.notice,
162 &facts.context(manifest, json!({ "kind": kind, "workspace": workspace })),
163 );
164 if rendered.is_empty() {
165 fallback_report(&facts)
166 } else {
167 rendered
168 }
169}
170
171struct Facts {
173 date: String,
175 model: String,
177 harness: String,
179}
180
181impl Facts {
182 fn context(&self, manifest: &Manifest, extra: serde_json::Value) -> serde_json::Value {
186 let serde_json::Value::Object(mut map) = extra else {
187 return serde_json::Value::Null;
188 };
189 map.insert("date".into(), json!(self.date));
190 map.insert("model".into(), json!(self.model));
191 map.insert("harness".into(), json!(self.harness));
192 let facts = render_template(
193 &manifest.facts,
194 &json!({ "date": self.date, "model": self.model, "harness": self.harness }),
195 )
196 .unwrap_or_default();
197 map.insert("facts".into(), json!(facts));
198 serde_json::Value::Object(map)
199 }
200}
201
202fn facts(model: &str, harness: &str) -> Facts {
203 Facts {
204 date: utc_now(),
205 model: model.to_owned(),
206 harness: harness.to_owned(),
207 }
208}
209
210fn render_text(text: &str, context: &serde_json::Value) -> String {
213 if text.is_empty() {
214 return String::new();
215 }
216 render_template(text, context).unwrap_or_default()
217}
218
219fn render_report(manifest: &Manifest, workspace: &Path, facts: &Facts, output: &str) -> String {
223 let mut lines = output.lines().filter(|line| !line.trim().is_empty());
224 let os = os_label(manifest, lines.next().unwrap_or("unknown"));
225 let shell = lines
226 .next()
227 .and_then(|line| line.strip_prefix("shell: "))
228 .unwrap_or("unknown")
229 .to_owned();
230 let rest: Vec<&str> = lines.collect();
231 let branch = rest
232 .iter()
233 .find_map(|l| l.strip_prefix("git branch: "))
234 .map(str::to_owned);
235 let tools: Vec<serde_json::Value> = manifest
236 .probe
237 .iter()
238 .map(|entry| {
239 let present = rest
240 .iter()
241 .any(|l| *l == format!("{}: present", entry.executable));
242 json!({ "name": entry.executable, "state": if present { "present" } else { "absent" } })
243 })
244 .collect();
245 let notes: Vec<String> = manifest
246 .probe
247 .iter()
248 .filter(|entry| {
249 entry.advice.is_some()
250 && rest
251 .iter()
252 .any(|l| *l == format!("{}: present", entry.executable))
253 })
254 .filter_map(|entry| entry.advice.clone())
255 .collect();
256 let context = facts.context(
257 manifest,
258 json!({
259 "os": os,
260 "shell": shell,
261 "workspace": workspace.display().to_string(),
262 "branch": branch,
263 "tools": tools,
264 "notes": notes,
265 }),
266 );
267 let rendered = render_text(&manifest.report, &context);
268 if rendered.is_empty() {
269 fallback_report(facts)
270 } else {
271 rendered
272 }
273}
274
275fn unavailable(facts: &Facts, reason: &str) -> String {
277 let manifest = manifest();
278 let rendered = render_text(
279 &manifest.unavailable,
280 &facts.context(manifest, json!({ "reason": reason })),
281 );
282 if rendered.is_empty() {
283 fallback_report(facts)
284 } else {
285 rendered
286 }
287}
288
289fn fallback_report(facts: &Facts) -> String {
292 format!(
293 "## Environment\n\n\
294 The report's data did not parse; ask the shell (`uname -sm`,\n\
295 `command -v <tool>`) when a fact matters.\n\n\
296 - Date: {}\n- Model: {}\n- Harness: {}\n",
297 facts.date, facts.model, facts.harness
298 )
299}
300
301fn os_label(manifest: &Manifest, raw: &str) -> String {
305 let Some((system, _rest)) = raw.split_once(' ') else {
306 return raw.to_owned();
307 };
308 match manifest.os_names.get(&system.to_lowercase()) {
309 Some(friendly) => format!("{friendly} ({raw})"),
310 None => raw.to_owned(),
311 }
312}
313
314fn run_probe(manifest: &Manifest, workspace: &Path) -> Result<String, String> {
319 let mut child = Command::new("bash")
320 .arg("-c")
321 .arg(probe_script(manifest))
322 .current_dir(workspace)
323 .stdin(Stdio::null())
324 .stdout(Stdio::piped())
325 .stderr(Stdio::null())
326 .spawn()
327 .map_err(|err| format!("cannot spawn the probe: {err}"))?;
328 let deadline = Instant::now() + PROBE_BUDGET;
329 let status = loop {
330 match child.try_wait() {
331 Ok(Some(status)) => break Some(status),
332 Ok(None) if Instant::now() >= deadline => break None,
333 Ok(None) => std::thread::sleep(POLL_STEP),
334 Err(err) => return Err(format!("cannot wait on the probe: {err}")),
335 }
336 };
337 let Some(status) = status else {
338 let _ = child.kill();
339 let _ = child.wait();
340 return Err(format!("no exit within {PROBE_BUDGET:?}"));
341 };
342 if !status.success() {
343 return Err(format!("probe exited: {status}"));
344 }
345 let mut bytes = Vec::new();
346 if let Some(mut stdout) = child.stdout.take() {
347 let _ = (&mut stdout)
348 .take(OUTPUT_CAP as u64)
349 .read_to_end(&mut bytes);
350 }
351 Ok(String::from_utf8_lossy(&bytes).into_owned())
352}
353
354fn utc_now() -> String {
357 let secs = SystemTime::now()
358 .duration_since(UNIX_EPOCH)
359 .unwrap_or_default()
360 .as_secs();
361 let (year, month, day) = civil_from_days((secs / 86_400) as i64);
362 let secs_of_day = secs % 86_400;
363 format!(
364 "{year:04}-{month:02}-{day:02} {:02}:{:02} UTC",
365 secs_of_day / 3_600,
366 secs_of_day % 3_600 / 60
367 )
368}
369
370fn civil_from_days(days: i64) -> (i64, u32, u32) {
373 let z = days + 719_468;
374 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
375 let doe = z - era * 146_097;
376 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
377 let year = yoe + era * 400;
378 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
379 let mp = (5 * doy + 2) / 153;
380 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
381 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
382 (if month <= 2 { year + 1 } else { year }, month, day)
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
394 fn the_baked_manifest_parses_and_every_text_renders() {
395 let manifest = manifest();
396 assert!(!manifest.probe.is_empty(), "an empty inventory is a bug");
397 assert!(!manifest.report.is_empty());
398 assert!(
399 !manifest.facts.is_empty(),
400 "the facts block is written once"
401 );
402 let facts = facts("m", "h");
403 for text in [
404 &manifest.facts,
405 &manifest.report,
406 &manifest.notice,
407 &manifest.unavailable,
408 ] {
409 let context = facts.context(
410 manifest,
411 json!({
412 "os": "macOS (Darwin arm64)", "shell": "/bin/zsh",
413 "workspace": "/ws", "branch": "main",
414 "tools": [{"name": "rg", "state": "present"}], "notes": ["n"],
415 "kind": "k", "reason": "r",
416 }),
417 );
418 let rendered = render_template(text, &context).expect("the text renders");
419 assert!(!rendered.trim().is_empty());
420 }
421 }
422
423 #[test]
428 fn the_host_report_carries_facts_and_presence_without_versions() {
429 let manifest = manifest();
430 let report = host_report(Path::new("."), "glm-5.3", "rhema test");
431 assert!(report.starts_with("## Environment\n"), "{report}");
432 for fact in [
433 "- Date: ",
434 "- Model: glm-5.3",
435 "- Harness: rhema test",
436 "### Machine",
437 "- OS: ",
438 "### Workspace",
439 "- CWD: ",
440 "### Command-line tools",
441 ] {
442 assert!(report.contains(fact), "missing {fact}: {report}");
443 }
444 for entry in &manifest.probe {
445 assert!(
446 report.contains(&format!("- {}: ", entry.executable)),
447 "no line for {}: {report}",
448 entry.executable
449 );
450 }
451 assert!(report.contains("- git branch: "), "{report}");
452 assert!(
453 !report.contains("version"),
454 "presence, not versions: {report}"
455 );
456 }
457
458 #[test]
461 fn the_os_line_maps_friendly_names_beside_the_raw() {
462 let manifest = manifest();
463 assert_eq!(os_label(manifest, "Darwin arm64"), "macOS (Darwin arm64)");
464 assert_eq!(os_label(manifest, "SunOS sun4u"), "SunOS sun4u");
465 }
466
467 #[test]
470 fn advice_follows_presence_not_the_tool_name() {
471 let manifest = manifest();
472 let facts = facts("m", "h");
473 let with = render_report(
474 manifest,
475 Path::new("/ws"),
476 &facts,
477 "Darwin arm64\nshell: /bin/zsh\nrg: present\nfd: absent\ngit branch: main\n",
478 );
479 assert!(with.contains("Notes:"), "{with}");
480 assert!(with.contains("Use `rg`"), "{with}");
481 let without = render_report(
482 manifest,
483 Path::new("/ws"),
484 &facts,
485 "Darwin arm64\nshell: /bin/zsh\nrg: absent\nfd: absent\n",
486 );
487 assert!(!without.contains("Notes:"), "{without}");
488 assert!(without.contains("- rg: absent"), "{without}");
489 }
490
491 #[test]
494 fn the_notice_names_the_kind_workspace_and_no_host_facts() {
495 let notice = remote_notice("docker", "/sandbox/ws", "glm-5.3", "rhema test");
496 assert!(notice.contains("docker sandbox"), "{notice}");
497 assert!(notice.contains("- Workspace: /sandbox/ws"), "{notice}");
498 assert!(notice.contains("- Model: glm-5.3"), "{notice}");
499 assert!(notice.contains("- Harness: rhema test"), "{notice}");
500 assert!(!notice.contains("- OS: "), "{notice}");
501 }
502
503 #[test]
506 fn unsafe_executables_never_reach_the_script() {
507 let mut manifest = manifest().clone();
508 manifest.probe.push(Probe {
509 executable: "x; rm -rf /".into(),
510 advice: None,
511 });
512 let script = probe_script(&manifest);
513 assert!(!script.contains("rm -rf"), "{script}");
514 }
515
516 #[test]
519 fn a_missing_workspace_renders_the_unavailable_text() {
520 let report = host_report(Path::new("/definitely/not/here"), "m", "h");
521 assert!(report.contains("did not answer"), "{report}");
522 assert!(report.contains("- Model: m"), "{report}");
523 }
524
525 #[test]
527 fn the_stamp_is_civil_utc() {
528 assert_eq!(civil_from_days(0), (1970, 1, 1));
529 assert_eq!(civil_from_days(19_723), (2024, 1, 1));
530 }
531}