1use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20use std::path::{Path, PathBuf};
21
22pub const MAX_CRITERIA: usize = 8;
25
26const MAX_EVIDENCE_CHARS: usize = 300;
28
29#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
30#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
31pub enum ExitCriterion {
32 FileExists { path: String },
34 FileContains { path: String, needle: String },
36 FileMinBytes { path: String, min_bytes: u64 },
38 ResultContains { needle: String },
40 Command {
44 run: String,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 expect_contains: Option<String>,
47 },
48}
49
50impl ExitCriterion {
51 pub fn describe(&self) -> String {
53 match self {
54 ExitCriterion::FileExists { path } => format!("file `{path}` exists"),
55 ExitCriterion::FileContains { path, needle } => {
56 format!("file `{path}` contains {needle:?}")
57 }
58 ExitCriterion::FileMinBytes { path, min_bytes } => {
59 format!("file `{path}` is at least {min_bytes} bytes")
60 }
61 ExitCriterion::ResultContains { needle } => {
62 format!("result text contains {needle:?}")
63 }
64 ExitCriterion::Command {
65 run,
66 expect_contains,
67 } => match expect_contains {
68 Some(needle) => format!("command `{run}` succeeds and prints {needle:?}"),
69 None => format!("command `{run}` succeeds"),
70 },
71 }
72 }
73}
74
75#[derive(Debug, Clone)]
77pub struct CriterionOutcome {
78 pub description: String,
79 pub passed: bool,
80 pub evidence: String,
81}
82
83#[async_trait]
86pub trait CommandRunner: Send + Sync {
87 async fn run(&self, command: &str) -> (bool, String);
89}
90
91pub fn parse_exit_criteria(
95 value: Option<&serde_json::Value>,
96) -> Result<Vec<ExitCriterion>, String> {
97 let Some(value) = value else {
98 return Ok(Vec::new());
99 };
100 if value.is_null() {
101 return Ok(Vec::new());
102 }
103 let arr = value
104 .as_array()
105 .ok_or_else(|| "exit_criteria must be an array".to_string())?;
106 if arr.len() > MAX_CRITERIA {
107 return Err(format!(
108 "exit_criteria has {} entries (max {MAX_CRITERIA})",
109 arr.len()
110 ));
111 }
112 let mut out = Vec::with_capacity(arr.len());
113 for (i, entry) in arr.iter().enumerate() {
114 let criterion: ExitCriterion = serde_json::from_value(entry.clone()).map_err(|e| {
115 format!(
116 "exit_criteria[{i}] is invalid: {e}. Expected one of: \
117 {{type: file_exists, path}}, {{type: file_contains, path, needle}}, \
118 {{type: file_min_bytes, path, min_bytes}}, {{type: result_contains, needle}}, \
119 {{type: command, run, expect_contains?}}"
120 )
121 })?;
122 if let ExitCriterion::FileExists { path }
123 | ExitCriterion::FileContains { path, .. }
124 | ExitCriterion::FileMinBytes { path, .. } = &criterion
125 {
126 validate_contract_path(path).map_err(|e| format!("exit_criteria[{i}]: {e}"))?;
127 }
128 out.push(criterion);
129 }
130 Ok(out)
131}
132
133fn validate_contract_path(path: &str) -> Result<(), String> {
141 let p = Path::new(path);
142 if p.is_absolute() || p.has_root() || path.starts_with('~') {
143 return Err(format!(
144 "path `{path}` must be relative to the chat working directory"
145 ));
146 }
147 if p.components().any(|c| {
148 matches!(
149 c,
150 std::path::Component::ParentDir | std::path::Component::Prefix(_)
151 )
152 }) {
153 return Err(format!(
154 "path `{path}` must not contain `..` or a drive/UNC prefix"
155 ));
156 }
157 Ok(())
158}
159
160fn truncate_evidence(s: &str) -> String {
161 let trimmed = s.trim();
162 if trimmed.len() <= MAX_EVIDENCE_CHARS {
163 return trimmed.to_string();
164 }
165 let end = microclaw_core::text::floor_char_boundary(trimmed, MAX_EVIDENCE_CHARS);
166 format!("{}…", &trimmed[..end])
167}
168
169pub async fn verify_criteria(
173 criteria: &[ExitCriterion],
174 result_text: &str,
175 working_dir: &Path,
176 runner: Option<&dyn CommandRunner>,
177) -> Vec<CriterionOutcome> {
178 let mut outcomes = Vec::with_capacity(criteria.len());
179 for criterion in criteria {
180 let description = criterion.describe();
181 let (passed, evidence) = match criterion {
182 ExitCriterion::FileExists { path } => {
183 let full = resolve(working_dir, path);
184 if full.is_file() {
185 (true, format!("{} exists", full.display()))
186 } else {
187 (false, format!("{} not found", full.display()))
188 }
189 }
190 ExitCriterion::FileContains { path, needle } => {
191 let full = resolve(working_dir, path);
192 match std::fs::read_to_string(&full) {
193 Ok(content) if content.contains(needle.as_str()) => {
194 (true, format!("{needle:?} found in {path}"))
195 }
196 Ok(_) => (false, format!("{needle:?} not found in {path}")),
197 Err(e) => (false, format!("cannot read {path}: {e}")),
198 }
199 }
200 ExitCriterion::FileMinBytes { path, min_bytes } => {
201 let full = resolve(working_dir, path);
202 match std::fs::metadata(&full) {
203 Ok(meta) if meta.len() >= *min_bytes => {
204 (true, format!("{path} is {} bytes", meta.len()))
205 }
206 Ok(meta) => (
207 false,
208 format!("{path} is {} bytes (need >= {min_bytes})", meta.len()),
209 ),
210 Err(e) => (false, format!("cannot stat {path}: {e}")),
211 }
212 }
213 ExitCriterion::ResultContains { needle } => {
214 if result_text.contains(needle.as_str()) {
215 (true, format!("{needle:?} present in result"))
216 } else {
217 (false, format!("{needle:?} missing from result"))
218 }
219 }
220 ExitCriterion::Command {
221 run,
222 expect_contains,
223 } => match runner {
224 Some(runner) => {
225 let (success, output) = runner.run(run).await;
226 match (success, expect_contains) {
227 (false, _) => (false, truncate_evidence(&output)),
228 (true, Some(needle)) if !output.contains(needle.as_str()) => (
229 false,
230 format!(
231 "command succeeded but output lacks {needle:?}: {}",
232 truncate_evidence(&output)
233 ),
234 ),
235 (true, _) => (true, truncate_evidence(&output)),
236 }
237 }
238 None => (
239 false,
240 "command criteria are not available in this runtime".to_string(),
241 ),
242 },
243 };
244 outcomes.push(CriterionOutcome {
245 description,
246 passed,
247 evidence: truncate_evidence(&evidence),
248 });
249 }
250 outcomes
251}
252
253fn resolve(working_dir: &Path, rel: &str) -> PathBuf {
254 working_dir.join(rel)
255}
256
257pub fn render_report(outcomes: &[CriterionOutcome]) -> String {
259 let passed = outcomes.iter().filter(|o| o.passed).count();
260 let total = outcomes.len();
261 let mut out = if passed == total {
262 format!("[completion contract] VERIFIED — {passed}/{total} criteria passed\n")
263 } else {
264 format!(
265 "[completion contract] FAILED — {passed}/{total} criteria passed. \
266 Do NOT treat this task as done; the failed criteria below are the \
267 remaining work.\n"
268 )
269 };
270 for o in outcomes {
271 let mark = if o.passed { "✓" } else { "✗" };
272 out.push_str(&format!("{mark} {} — {}\n", o.description, o.evidence));
273 }
274 out
275}
276
277pub fn render_for_prompt(criteria: &[ExitCriterion]) -> String {
280 let mut out = String::from(
281 "\n\nCompletion contract: after you finish, the runtime will VERIFY these \
282 criteria with real checks (file reads / commands). Your result is only \
283 accepted as done if all pass — make each one true before submitting:\n",
284 );
285 for c in criteria {
286 out.push_str(&format!("- {}\n", c.describe()));
287 }
288 out
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use serde_json::json;
295
296 struct StubRunner {
297 success: bool,
298 output: &'static str,
299 }
300
301 #[async_trait]
302 impl CommandRunner for StubRunner {
303 async fn run(&self, _command: &str) -> (bool, String) {
304 (self.success, self.output.to_string())
305 }
306 }
307
308 fn temp_dir(tag: &str) -> PathBuf {
309 let dir = std::env::temp_dir().join(format!(
310 "microclaw-contract-{tag}-{}-{}",
311 std::process::id(),
312 std::time::SystemTime::now()
313 .duration_since(std::time::UNIX_EPOCH)
314 .unwrap()
315 .as_nanos()
316 ));
317 std::fs::create_dir_all(&dir).unwrap();
318 dir
319 }
320
321 #[test]
322 fn parse_accepts_valid_and_rejects_invalid() {
323 let ok = parse_exit_criteria(Some(&json!([
324 {"type": "file_exists", "path": "report.md"},
325 {"type": "file_contains", "path": "report.md", "needle": "## Summary"},
326 {"type": "file_min_bytes", "path": "out.pdf", "min_bytes": 10240},
327 {"type": "result_contains", "needle": "done"},
328 {"type": "command", "run": "cargo test -q"},
329 {"type": "command", "run": "wc -l x", "expect_contains": "30"}
330 ])))
331 .unwrap();
332 assert_eq!(ok.len(), 6);
333
334 assert!(parse_exit_criteria(None).unwrap().is_empty());
335 assert!(parse_exit_criteria(Some(&json!(null))).unwrap().is_empty());
336 assert!(parse_exit_criteria(Some(&json!("nope"))).is_err());
337 assert!(parse_exit_criteria(Some(&json!([{"type": "bogus"}]))).is_err());
338 assert!(
340 parse_exit_criteria(Some(&json!([{"type": "file_exists", "path": "a", "x": 1}])))
341 .is_err()
342 );
343 }
344
345 #[test]
346 fn parse_rejects_unsafe_paths_and_oversize() {
347 assert!(parse_exit_criteria(Some(&json!([
348 {"type": "file_exists", "path": "/etc/passwd"}
349 ])))
350 .is_err());
351 assert!(parse_exit_criteria(Some(&json!([
352 {"type": "file_exists", "path": "../secrets"}
353 ])))
354 .is_err());
355 assert!(parse_exit_criteria(Some(&json!([
356 {"type": "file_contains", "path": "~/.ssh/id_rsa", "needle": "x"}
357 ])))
358 .is_err());
359 let too_many: Vec<_> = (0..9)
360 .map(|i| json!({"type": "file_exists", "path": format!("f{i}")}))
361 .collect();
362 assert!(parse_exit_criteria(Some(&json!(too_many))).is_err());
363 }
364
365 #[tokio::test]
366 async fn file_criteria_verify_against_working_dir() {
367 let dir = temp_dir("files");
368 std::fs::write(dir.join("report.md"), "# Report\n## Summary\nAll good.").unwrap();
369
370 let criteria = vec![
371 ExitCriterion::FileExists {
372 path: "report.md".into(),
373 },
374 ExitCriterion::FileContains {
375 path: "report.md".into(),
376 needle: "## Summary".into(),
377 },
378 ExitCriterion::FileMinBytes {
379 path: "report.md".into(),
380 min_bytes: 10,
381 },
382 ExitCriterion::FileExists {
383 path: "missing.md".into(),
384 },
385 ];
386 let outcomes = verify_criteria(&criteria, "", &dir, None).await;
387 assert_eq!(
388 outcomes.iter().map(|o| o.passed).collect::<Vec<_>>(),
389 vec![true, true, true, false]
390 );
391
392 std::fs::remove_dir_all(&dir).ok();
393 }
394
395 #[tokio::test]
396 async fn result_and_command_criteria() {
397 let dir = temp_dir("cmd");
398 let criteria = vec![
399 ExitCriterion::ResultContains {
400 needle: "30 files updated".into(),
401 },
402 ExitCriterion::Command {
403 run: "true".into(),
404 expect_contains: None,
405 },
406 ];
407
408 let ok_runner = StubRunner {
409 success: true,
410 output: "ok",
411 };
412 let outcomes =
413 verify_criteria(&criteria, "Done: 30 files updated.", &dir, Some(&ok_runner)).await;
414 assert!(outcomes.iter().all(|o| o.passed));
415
416 let fail_runner = StubRunner {
418 success: true,
419 output: "27 matches",
420 };
421 let criteria = vec![ExitCriterion::Command {
422 run: "grep -c x".into(),
423 expect_contains: Some("30".into()),
424 }];
425 let outcomes = verify_criteria(&criteria, "", &dir, Some(&fail_runner)).await;
426 assert!(!outcomes[0].passed);
427 assert!(outcomes[0].evidence.contains("lacks"));
428
429 let outcomes = verify_criteria(&criteria, "", &dir, None).await;
430 assert!(!outcomes[0].passed);
431
432 std::fs::remove_dir_all(&dir).ok();
433 }
434
435 #[test]
436 fn report_renders_verified_and_failed() {
437 let verified = render_report(&[CriterionOutcome {
438 description: "file `x` exists".into(),
439 passed: true,
440 evidence: "x exists".into(),
441 }]);
442 assert!(verified.contains("VERIFIED — 1/1"));
443
444 let failed = render_report(&[
445 CriterionOutcome {
446 description: "a".into(),
447 passed: true,
448 evidence: "e1".into(),
449 },
450 CriterionOutcome {
451 description: "b".into(),
452 passed: false,
453 evidence: "e2".into(),
454 },
455 ]);
456 assert!(failed.contains("FAILED — 1/2"));
457 assert!(failed.contains("✗ b — e2"));
458 }
459
460 #[test]
461 fn prompt_rendering_lists_criteria() {
462 let text = render_for_prompt(&[ExitCriterion::FileExists {
463 path: "out.pdf".into(),
464 }]);
465 assert!(text.contains("Completion contract"));
466 assert!(text.contains("file `out.pdf` exists"));
467 }
468}