1use harn_vm::value::DictMap;
24use harn_vm::{VmDictExt, VmValue};
25
26use crate::error::HostlibError;
27use crate::registry::{BuiltinRegistry, HostlibCapability};
28use crate::tools::inspect_test_results::{get_run, AuthorizedTestPlanIdentity};
29use crate::tools::payload::{optional_string, require_dict_arg, require_string};
30
31pub const ISSUE_BUILTIN: &str = "__harness_verdict_issue";
34
35pub struct VerdictCapability;
37
38impl HostlibCapability for VerdictCapability {
39 fn module_name(&self) -> &'static str {
40 "verdict"
41 }
42
43 fn register_builtins(&self, registry: &mut BuiltinRegistry) {
44 registry.register_fn("verdict", ISSUE_BUILTIN, "issue", verdict_issue_builtin);
48 }
49}
50
51fn verdict_issue_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
52 let map = require_dict_arg(ISSUE_BUILTIN, args)?;
53 let result_handle = require_string(ISSUE_BUILTIN, &map, "result_handle")?;
54 let subject = optional_string(ISSUE_BUILTIN, &map, "subject")?;
55
56 let Some(run) = get_run(&result_handle) else {
60 return Ok(outcome_dict(
61 "unavailable",
62 0,
63 0,
64 &result_handle,
65 "",
66 None,
67 None,
68 subject.as_deref(),
69 "no host execution recorded under this result handle; a positive verdict requires a real run_test execution",
70 ));
71 };
72 let owner = run.execution_scope.as_deref();
73
74 let active = harn_vm::current_execution_scope();
80 let scope_ok = matches!((&active, &run.execution_scope), (Some(a), Some(o)) if a == o);
81 if !scope_ok {
82 return Ok(outcome_dict(
83 "unavailable",
84 0,
85 0,
86 &result_handle,
87 &run.content_hash,
88 owner,
89 run.artifacts.authorized_test_plan.as_ref(),
90 subject.as_deref(),
91 "result handle belongs to a different or ended execution; a positive verdict must be issued within the run that produced it",
92 ));
93 }
94
95 if run.artifacts.exit_code != 0 {
99 return Ok(outcome_dict(
100 "fail",
101 0,
102 0,
103 &result_handle,
104 &run.content_hash,
105 owner,
106 run.artifacts.authorized_test_plan.as_ref(),
107 subject.as_deref(),
108 "host execution exited nonzero",
109 ));
110 }
111
112 let Some(summary) = run.summary else {
116 return Ok(outcome_dict(
117 "unavailable",
118 0,
119 0,
120 &result_handle,
121 &run.content_hash,
122 owner,
123 run.artifacts.authorized_test_plan.as_ref(),
124 subject.as_deref(),
125 "host execution produced no recognized test results",
126 ));
127 };
128 let total = summary.passed + summary.failed + summary.skipped;
129 if summary.failed > 0 {
130 return Ok(outcome_dict(
131 "fail",
132 summary.passed,
133 total,
134 &result_handle,
135 &run.content_hash,
136 owner,
137 run.artifacts.authorized_test_plan.as_ref(),
138 subject.as_deref(),
139 "host execution reported failing tests",
140 ));
141 }
142 if summary.passed == 0 {
143 return Ok(outcome_dict(
144 "unavailable",
145 0,
146 total,
147 &result_handle,
148 &run.content_hash,
149 owner,
150 run.artifacts.authorized_test_plan.as_ref(),
151 subject.as_deref(),
152 "host execution reported zero passing tests",
153 ));
154 }
155
156 let Some(plan) = run.artifacts.authorized_test_plan.as_ref() else {
161 return Ok(outcome_dict(
162 "unavailable",
163 0,
164 total,
165 &result_handle,
166 &run.content_hash,
167 owner,
168 None,
169 subject.as_deref(),
170 "execution was not produced by a host-discovered test plan bound to the active workspace",
171 ));
172 };
173 Ok(outcome_dict(
174 "pass",
175 summary.passed,
176 total,
177 &result_handle,
178 &run.content_hash,
179 owner,
180 Some(plan),
181 subject.as_deref(),
182 "",
183 ))
184}
185
186#[allow(clippy::too_many_arguments)]
187fn outcome_dict(
188 outcome: &str,
189 passed: u32,
190 total: u32,
191 artifact_id: &str,
192 artifact_hash: &str,
193 execution_scope: Option<&str>,
194 plan: Option<&AuthorizedTestPlanIdentity>,
195 subject: Option<&str>,
196 detail: &str,
197) -> VmValue {
198 let mut map = DictMap::new();
199 map.put_str("outcome", outcome);
200 map.put_int("passed", i64::from(passed));
201 map.put_int("total", i64::from(total));
202 map.put_str("artifact_id", artifact_id);
203 map.put_str("artifact_hash", artifact_hash);
204 map.put_opt_str("execution_scope", execution_scope);
206 map.put_opt_str("plan_id", plan.map(|identity| identity.plan_id.as_str()));
207 map.put_opt_str(
208 "workspace_hash",
209 plan.map(|identity| identity.workspace_hash.as_str()),
210 );
211 map.put_opt_str(
212 "command_hash",
213 plan.map(|identity| identity.command_hash.as_str()),
214 );
215 map.put_opt_str("subject", subject);
216 map.put_str("detail", detail);
217 VmValue::dict_map(map)
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::process::{install_spawner, MockProcessConfig, MockSpawner, SpawnerGuard};
224 use crate::tools::inspect_test_results::{store_run, RawArtifacts, TestSummaryData};
225 use harn_lexer::Lexer;
226 use harn_parser::Parser;
227 use harn_vm::orchestration::RunExecutionRecord;
228 use harn_vm::{enter_execution_scope, mint_execution_scope, register_vm_stdlib, Compiler, Vm};
229 use std::path::Path;
230 use std::sync::Arc;
231 use tempfile::TempDir;
232
233 const PASSING_CARGO_OUTPUT: &str = "running 1 test\n\
234test tests::green ... ok\n\n\
235test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n";
236
237 fn issue_arg(result_handle: &str) -> Vec<VmValue> {
240 let mut map = DictMap::new();
241 map.put_str("result_handle", result_handle);
242 vec![VmValue::dict_map(map)]
243 }
244
245 fn outcome_of(v: &VmValue) -> String {
246 v.as_dict()
247 .and_then(|d| d.get("outcome"))
248 .map(|o| o.as_str_cow().into_owned())
249 .unwrap_or_default()
250 }
251
252 fn artifacts(stdout: &str, exit_code: i32) -> RawArtifacts {
253 RawArtifacts {
254 stdout: stdout.to_string(),
255 stderr: String::new(),
256 exit_code,
257 junit_path: None,
258 ecosystem: None,
259 argv: Vec::new(),
260 authorized_test_plan: None,
261 }
262 }
263
264 fn cargo_fixture(parent: &Path, name: &str) -> TempDir {
265 let dir = tempfile::Builder::new()
266 .prefix(name)
267 .tempdir_in(parent)
268 .expect("fixture dir");
269 std::fs::write(
270 dir.path().join("Cargo.toml"),
271 format!(
272 "[package]\nname = \"{name}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[lib]\npath = \"lib.rs\"\n"
273 ),
274 )
275 .expect("fixture manifest");
276 std::fs::write(
277 dir.path().join("lib.rs"),
278 "#[cfg(test)]\nmod tests {\n #[test]\n fn green() {}\n}\n",
279 )
280 .expect("fixture source");
281 dir
282 }
283
284 fn install_passing_cargo_spawner(count: usize) -> (Arc<MockSpawner>, SpawnerGuard) {
285 let spawner = Arc::new(MockSpawner::new());
286 for offset in 0..count {
287 let mut config = MockProcessConfig::with_stdout(0, PASSING_CARGO_OUTPUT);
288 config.pid += u32::try_from(offset).expect("fixture spawn count fits in u32");
289 let _controller = spawner.enqueue(config);
290 }
291 let guard = install_spawner(spawner.clone());
292 (spawner, guard)
293 }
294
295 fn assert_cargo_plan(spawner: &MockSpawner, count: usize, filter: Option<&str>) {
296 let expected_args = match filter {
297 Some(filter) => vec!["test".to_string(), filter.to_string()],
298 None => vec!["test".to_string()],
299 };
300 let captured = spawner.captured();
301 assert_eq!(captured.len(), count, "every expected plan must execute");
302 for spec in captured {
303 assert_eq!(spec.program, "cargo");
304 assert_eq!(spec.args, expected_args);
305 }
306 }
307
308 fn run_test_in(cwd: &Path, argv: Option<Vec<&str>>, filter: Option<&str>) -> VmValue {
309 let mut req = DictMap::new();
310 req.put_str("cwd", cwd.to_string_lossy());
311 if let Some(argv) = argv {
312 req.put(
313 "argv",
314 VmValue::List(Arc::new(argv.into_iter().map(VmValue::string).collect())),
315 );
316 }
317 req.put_opt_str("filter", filter);
318 crate::tools::run_test::handle(&[VmValue::dict_map(req)]).expect("run_test ok")
319 }
320
321 fn result_handle(result: &VmValue) -> String {
322 result
323 .as_dict()
324 .and_then(|dict| dict.get("result_handle"))
325 .map(|handle| handle.as_str_cow().into_owned())
326 .expect("run_test returns a result_handle")
327 }
328
329 fn compile(source: &str) -> harn_vm::Chunk {
330 let mut lexer = Lexer::new(source);
331 let tokens = lexer.tokenize().expect("tokenize");
332 let mut parser = Parser::new(tokens);
333 let program = parser.parse().expect("parse");
334 Compiler::new().compile(&program).expect("compile")
335 }
336
337 fn harn_string(value: &Path) -> String {
338 value
339 .to_string_lossy()
340 .replace('\\', "\\\\")
341 .replace('"', "\\\"")
342 }
343
344 fn overlap_vm(barrier: Arc<tokio::sync::Barrier>) -> Vm {
345 let mut vm = Vm::new();
346 register_vm_stdlib(&mut vm);
347 let _ = crate::install_default(&mut vm);
348 vm.register_async_builtin("__test_overlap", move |_ctx, _args| {
349 let barrier = barrier.clone();
350 async move {
351 barrier.wait().await;
352 Ok(VmValue::Nil)
353 }
354 });
355 vm
356 }
357
358 fn record_in_scope(
363 scope: &Arc<str>,
364 arts: RawArtifacts,
365 summary: Option<TestSummaryData>,
366 ) -> String {
367 let _g = enter_execution_scope(scope.clone());
368 store_run(arts, summary)
369 }
370
371 #[cfg(unix)]
374 #[test]
375 fn arbitrary_passing_command_cannot_issue_pass() {
376 let _scope = enter_execution_scope(mint_execution_scope());
377 let cwd = std::env::current_dir().expect("cwd");
378 let res = run_test_in(
379 &cwd,
380 Some(vec![
381 "sh",
382 "-c",
383 "printf 'running 1 test\\ntest a ... ok\\n\\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\\n'",
384 ]),
385 None,
386 );
387 let handle = result_handle(&res);
388 let out = verdict_issue_builtin(&issue_arg(&handle)).expect("issue ok");
389 assert_eq!(outcome_of(&out), "unavailable");
390 }
391
392 #[test]
396 fn host_discovered_test_plan_issues_pass() {
397 let workspace = TempDir::new().expect("workspace");
398 let fixture = cargo_fixture(workspace.path(), "verdict_positive");
399 let (spawner, _spawner_guard) = install_passing_cargo_spawner(1);
400 harn_vm::stdlib::process::set_thread_execution_context(Some(RunExecutionRecord {
401 cwd: Some(fixture.path().to_string_lossy().into_owned()),
402 project_root: Some(fixture.path().to_string_lossy().into_owned()),
403 ..RunExecutionRecord::default()
404 }));
405 let _scope = enter_execution_scope(mint_execution_scope());
406 let res = run_test_in(fixture.path(), None, None);
407 let out = verdict_issue_builtin(&issue_arg(&result_handle(&res))).expect("issue ok");
408 harn_vm::stdlib::process::set_thread_execution_context(None);
409 assert_cargo_plan(&spawner, 1, None);
410 assert_eq!(outcome_of(&out), "pass");
411 }
412
413 #[test]
416 fn filtered_discovered_test_plan_cannot_issue_pass() {
417 let workspace = TempDir::new().expect("workspace");
418 let fixture = cargo_fixture(workspace.path(), "verdict_filtered");
419 let (spawner, _spawner_guard) = install_passing_cargo_spawner(1);
420 harn_vm::stdlib::process::set_thread_execution_context(Some(RunExecutionRecord {
421 cwd: Some(fixture.path().to_string_lossy().into_owned()),
422 project_root: Some(fixture.path().to_string_lossy().into_owned()),
423 ..RunExecutionRecord::default()
424 }));
425 let _scope = enter_execution_scope(mint_execution_scope());
426 let res = run_test_in(fixture.path(), None, Some("green"));
427 let out = verdict_issue_builtin(&issue_arg(&result_handle(&res))).expect("issue ok");
428 harn_vm::stdlib::process::set_thread_execution_context(None);
429 assert_cargo_plan(&spawner, 1, Some("green"));
430 assert_eq!(outcome_of(&out), "unavailable");
431 }
432
433 #[tokio::test(flavor = "current_thread")]
438 async fn overlapping_top_level_vms_issue_only_their_own_receipts() {
439 tokio::task::LocalSet::new()
440 .run_until(async {
441 let workspace = TempDir::new().expect("workspace");
442 let fixture = cargo_fixture(workspace.path(), "verdict_overlap");
443 let (spawner, _spawner_guard) = install_passing_cargo_spawner(2);
444 harn_vm::stdlib::process::set_thread_execution_context(Some(RunExecutionRecord {
445 cwd: Some(fixture.path().to_string_lossy().into_owned()),
446 project_root: Some(fixture.path().to_string_lossy().into_owned()),
447 ..RunExecutionRecord::default()
448 }));
449
450 let source = |cwd: &Path| {
451 format!(
452 r#"
453import {{ verdict_disposition, verdict_from_run }} from "std/agent/verdict"
454
455pipeline default(harness: Harness, _task) {{
456 const run = harness.tools.run_test({{cwd: "{}", timeout_ms: 120000}})
457 __test_overlap()
458 return verdict_disposition(verdict_from_run(harness.verdict, run))
459}}
460"#,
461 harn_string(cwd)
462 )
463 };
464 let chunk_a = compile(&source(fixture.path()));
465 let chunk_b = compile(&source(fixture.path()));
466 let barrier = Arc::new(tokio::sync::Barrier::new(2));
467 let mut vm_a = overlap_vm(barrier.clone());
468 let mut vm_b = overlap_vm(barrier);
469
470 let (result_a, result_b) =
471 tokio::join!(vm_a.execute(&chunk_a), vm_b.execute(&chunk_b));
472 harn_vm::stdlib::process::set_thread_execution_context(None);
473 assert_cargo_plan(&spawner, 2, None);
474 assert_eq!(result_a.expect("vm A").display(), "pass");
475 assert_eq!(result_b.expect("vm B").display(), "pass");
476 })
477 .await;
478 }
479
480 #[test]
483 fn unrecorded_handle_is_never_a_pass() {
484 let _scope = enter_execution_scope(mint_execution_scope());
485 let out = verdict_issue_builtin(&issue_arg("htr-deadbeef-999999")).expect("issue ok");
486 assert_eq!(outcome_of(&out), "unavailable");
487 }
488
489 #[test]
492 fn cross_scope_handle_is_rejected() {
493 let scope_a: Arc<str> = mint_execution_scope();
494 let handle = record_in_scope(
495 &scope_a,
496 artifacts("test result: ok. 2 passed; 0 failed", 0),
497 Some(TestSummaryData {
498 passed: 2,
499 failed: 0,
500 skipped: 0,
501 }),
502 );
503 let _scope_b = enter_execution_scope(mint_execution_scope());
504 let out = verdict_issue_builtin(&issue_arg(&handle)).expect("issue ok");
505 assert_eq!(outcome_of(&out), "unavailable");
506 }
507
508 #[test]
511 fn no_active_scope_is_rejected() {
512 let scope_a: Arc<str> = mint_execution_scope();
513 let handle = record_in_scope(
514 &scope_a,
515 artifacts("test result: ok. 1 passed; 0 failed", 0),
516 Some(TestSummaryData {
517 passed: 1,
518 failed: 0,
519 skipped: 0,
520 }),
521 );
522 let out = verdict_issue_builtin(&issue_arg(&handle)).expect("issue ok");
524 assert_eq!(outcome_of(&out), "unavailable");
525 }
526
527 #[test]
530 fn nonzero_exit_with_passing_text_is_not_a_pass() {
531 let _g = enter_execution_scope(mint_execution_scope());
532 let handle = store_run(
533 artifacts("test result: ok. 1 passed; 0 failed", 1),
534 Some(TestSummaryData {
535 passed: 1,
536 failed: 0,
537 skipped: 0,
538 }),
539 );
540 let out = verdict_issue_builtin(&issue_arg(&handle)).expect("issue ok");
541 assert_eq!(outcome_of(&out), "fail");
542 }
543
544 #[test]
547 fn red_host_execution_issues_fail() {
548 let _g = enter_execution_scope(mint_execution_scope());
549 let handle = store_run(
550 artifacts("test result: FAILED. 1 passed; 1 failed", 1),
551 Some(TestSummaryData {
552 passed: 1,
553 failed: 1,
554 skipped: 0,
555 }),
556 );
557 let out = verdict_issue_builtin(&issue_arg(&handle)).expect("issue ok");
558 assert_eq!(outcome_of(&out), "fail");
559 }
560
561 #[test]
564 fn recorded_but_unparsed_execution_is_unavailable() {
565 let _g = enter_execution_scope(mint_execution_scope());
566 let handle = store_run(artifacts("not test output", 0), None);
567 let out = verdict_issue_builtin(&issue_arg(&handle)).expect("issue ok");
568 assert_eq!(outcome_of(&out), "unavailable");
569 }
570}