1use std::collections::BTreeMap;
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31use serde_json::{json, Value};
32
33use crate::error::{Error, Result};
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
37#[serde(deny_unknown_fields)]
38pub struct FieldPredicateSpec {
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub equals: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub contains: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub pattern: Option<String>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55#[serde(untagged)]
56pub enum FieldPredicate {
57 Equals(String),
59 Spec(FieldPredicateSpec),
61}
62
63impl FieldPredicate {
64 fn validate(&self, who: &str, key: &str) -> Result<()> {
66 match self {
67 FieldPredicate::Equals(_) => Ok(()),
68 FieldPredicate::Spec(FieldPredicateSpec {
69 equals,
70 contains,
71 pattern,
72 }) => {
73 if equals.is_none() && contains.is_none() && pattern.is_none() {
74 return Err(Error::Invalid(format!(
75 "{who}: `input.{key}` needs one of `equals`/`contains`/`pattern`"
76 )));
77 }
78 if contains.as_deref() == Some("") || pattern.as_deref() == Some("") {
79 return Err(Error::Invalid(format!(
80 "{who}: empty `contains`/`pattern` in `input.{key}` would match everything"
81 )));
82 }
83 if let Some(pattern) = pattern {
84 compile_pattern(pattern, &format!("{who}: `input.{key}.pattern`"))?;
85 }
86 Ok(())
87 }
88 }
89 }
90
91 fn matches(&self, value: &str) -> bool {
93 match self {
94 FieldPredicate::Equals(want) => value == want,
95 FieldPredicate::Spec(FieldPredicateSpec {
96 equals,
97 contains,
98 pattern,
99 }) => {
100 if let Some(want) = equals {
101 if value != want {
102 return false;
103 }
104 }
105 if let Some(needle) = contains {
106 if needle.is_empty() || !value.contains(needle.as_str()) {
107 return false;
108 }
109 }
110 if let Some(pattern) = pattern {
111 match regex::Regex::new(pattern) {
114 Ok(re) if re.is_match(value) => {}
115 _ => return false,
116 }
117 }
118 true
119 }
120 }
121 }
122
123 fn to_oneharness(&self) -> Value {
125 match self {
126 FieldPredicate::Equals(want) => json!({ "equals": want }),
127 FieldPredicate::Spec(FieldPredicateSpec {
128 equals,
129 contains,
130 pattern,
131 }) => {
132 let mut spec = serde_json::Map::new();
133 if let Some(v) = equals {
134 spec.insert("equals".into(), json!(v));
135 }
136 if let Some(v) = contains {
137 spec.insert("contains".into(), json!(v));
138 }
139 if let Some(v) = pattern {
140 spec.insert("regex".into(), json!(v));
141 }
142 Value::Object(spec)
143 }
144 }
145 }
146}
147
148#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
151#[serde(deny_unknown_fields)]
152pub struct MockMatch {
153 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub tool: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub contains: Option<String>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub pattern: Option<String>,
167 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
172 pub input: BTreeMap<String, FieldPredicate>,
173}
174
175impl MockMatch {
176 fn validate(&self, who: &str) -> Result<()> {
178 for (field, value) in [
179 ("tool", &self.tool),
180 ("contains", &self.contains),
181 ("pattern", &self.pattern),
182 ] {
183 if value.as_deref() == Some("") {
184 return Err(Error::Invalid(format!(
185 "{who}: empty `match.{field}` would match everything"
186 )));
187 }
188 }
189 if let Some(pattern) = &self.pattern {
190 compile_pattern(pattern, &format!("{who}: `match.pattern`"))?;
191 }
192 for (key, pred) in &self.input {
193 pred.validate(who, key)?;
194 }
195 if self.tool.is_none()
196 && self.contains.is_none()
197 && self.pattern.is_none()
198 && self.input.is_empty()
199 {
200 return Err(Error::Invalid(format!(
201 "{who}: `match` needs at least one of `tool`, `contains`, `pattern`, or `input`"
202 )));
203 }
204 Ok(())
205 }
206
207 #[must_use]
212 pub fn matches_call(&self, tool: Option<&str>, input: Option<&Value>) -> bool {
213 if let Some(want) = &self.tool {
214 match tool {
215 Some(name) if name.eq_ignore_ascii_case(want) => {}
216 _ => return false,
217 }
218 }
219 if self.contains.is_some() || self.pattern.is_some() {
220 let event = event_haystack(tool, input);
221 if let Some(needle) = &self.contains {
222 if needle.is_empty() || !event.contains(needle.as_str()) {
223 return false;
224 }
225 }
226 if let Some(pattern) = &self.pattern {
227 match regex::Regex::new(pattern) {
228 Ok(re) if re.is_match(&event) => {}
229 _ => return false,
230 }
231 }
232 }
233 where_matches(&self.input, input)
234 }
235
236 fn to_oneharness(&self) -> Value {
238 let mut spec = serde_json::Map::new();
239 if let Some(tool) = &self.tool {
240 spec.insert("tool".into(), json!(tool));
241 }
242 if let Some(contains) = &self.contains {
243 spec.insert("event_contains".into(), json!(contains));
244 }
245 if let Some(pattern) = &self.pattern {
246 spec.insert("event_regex".into(), json!(pattern));
247 }
248 if !self.input.is_empty() {
249 let input: serde_json::Map<String, Value> = self
250 .input
251 .iter()
252 .map(|(k, p)| (k.clone(), p.to_oneharness()))
253 .collect();
254 spec.insert("input".into(), Value::Object(input));
255 }
256 Value::Object(spec)
257 }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
262#[serde(deny_unknown_fields)]
263pub struct StubOutput {
264 pub output: String,
265 #[serde(default)]
267 pub exit_code: i32,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
275#[serde(untagged)]
276pub enum StubSpec {
277 Text(String),
279 Full(StubOutput),
280}
281
282impl StubSpec {
283 #[must_use]
285 pub fn output(&self) -> &str {
286 match self {
287 StubSpec::Text(output) | StubSpec::Full(StubOutput { output, .. }) => output,
288 }
289 }
290
291 #[must_use]
293 pub fn exit_code(&self) -> i32 {
294 match self {
295 StubSpec::Text(_) => 0,
296 StubSpec::Full(StubOutput { exit_code, .. }) => *exit_code,
297 }
298 }
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
303#[serde(deny_unknown_fields)]
304pub struct DenyMessage {
305 pub message: String,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
311#[serde(untagged)]
312pub enum DenySpec {
313 Text(String),
314 Full(DenyMessage),
315}
316
317impl DenySpec {
318 #[must_use]
320 pub fn message(&self) -> &str {
321 match self {
322 DenySpec::Text(message) | DenySpec::Full(DenyMessage { message }) => message,
323 }
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
332#[serde(deny_unknown_fields)]
333pub struct MockDecl {
334 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub name: Option<String>,
339 #[serde(rename = "match")]
340 pub matcher: MockMatch,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub stub: Option<StubSpec>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub deny: Option<DenySpec>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub rewrite: Option<Value>,
353}
354
355impl MockDecl {
356 #[must_use]
358 pub fn is_mock(&self) -> bool {
359 self.stub.is_some() || self.deny.is_some() || self.rewrite.is_some()
360 }
361
362 pub fn validate(&self, who: &str) -> Result<()> {
368 if self.name.as_deref() == Some("") {
369 return Err(Error::Invalid(format!("{who}: `name` must not be empty")));
370 }
371 self.matcher.validate(who)?;
372 let actions = usize::from(self.stub.is_some())
373 + usize::from(self.deny.is_some())
374 + usize::from(self.rewrite.is_some());
375 if actions > 1 {
376 return Err(Error::Invalid(format!(
377 "{who}: declare at most one of `stub`/`deny`/`rewrite` (omit all three for a spy)"
378 )));
379 }
380 if let Some(rewrite) = &self.rewrite {
381 if !rewrite.is_object() {
382 return Err(Error::Invalid(format!(
383 "{who}: `rewrite` must be a JSON object (the substituted tool arguments)"
384 )));
385 }
386 }
387 if let Some(StubSpec::Text(text)) = &self.stub {
388 if text.is_empty() {
389 return Err(Error::Invalid(format!(
390 "{who}: `stub` output must not be empty (use `deny` to block a call)"
391 )));
392 }
393 }
394 Ok(())
395 }
396
397 fn action_json(&self) -> Option<Value> {
399 if let Some(stub) = &self.stub {
400 return Some(json!({
401 "stub": { "output": stub.output(), "exit_code": stub.exit_code() }
402 }));
403 }
404 if let Some(deny) = &self.deny {
405 return Some(json!({ "deny": { "message": deny.message() } }));
406 }
407 self.rewrite
408 .as_ref()
409 .map(|input| json!({ "rewrite": { "input": input } }))
410 }
411
412 #[must_use]
414 pub fn action_kind(&self) -> Option<&'static str> {
415 if self.stub.is_some() {
416 Some("stub")
417 } else if self.deny.is_some() {
418 Some("deny")
419 } else if self.rewrite.is_some() {
420 Some("rewrite")
421 } else {
422 None
423 }
424 }
425}
426
427#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
433pub struct MockCall {
434 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub tool: Option<String>,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub input: Option<Value>,
440 pub action: String,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub rule: Option<usize>,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub mock: Option<String>,
451}
452
453impl MockCall {
454 #[must_use]
456 pub fn mocked(&self) -> bool {
457 self.action != "allow"
458 }
459}
460
461#[derive(Debug, Clone, Default)]
466pub struct MockSet {
467 decls: Vec<MockDecl>,
468 rules: Option<Value>,
471 rule_to_decl: Vec<usize>,
473 spy_requested: bool,
476}
477
478impl MockSet {
479 pub fn build(shared: &[MockDecl], case: &[MockDecl], spy_requested: bool) -> Result<MockSet> {
486 let decls: Vec<MockDecl> = shared.iter().chain(case.iter()).cloned().collect();
487 let mut seen = std::collections::BTreeSet::new();
488 for (i, decl) in decls.iter().enumerate() {
489 decl.validate(&format!("mock `{}`", decl_label(decl, i)))?;
490 if let Some(name) = &decl.name {
491 if !seen.insert(name.clone()) {
492 return Err(Error::Invalid(format!(
493 "duplicate mock name `{name}` (names must be unique across --mocks and the case)"
494 )));
495 }
496 }
497 }
498 let mut rules = Vec::new();
499 let mut rule_to_decl = Vec::new();
500 for (i, decl) in decls.iter().enumerate() {
501 if let Some(action) = decl.action_json() {
502 rules.push(json!({ "match": decl.matcher.to_oneharness(), "action": action }));
503 rule_to_decl.push(i);
504 }
505 }
506 Ok(MockSet {
507 decls,
508 rules: (!rules.is_empty()).then(|| json!({ "rules": rules })),
509 rule_to_decl,
510 spy_requested,
511 })
512 }
513
514 #[must_use]
516 pub fn active(&self) -> bool {
517 self.spy_requested || !self.decls.is_empty()
518 }
519
520 #[must_use]
522 pub fn rules(&self) -> Option<&Value> {
523 self.rules.as_ref()
524 }
525
526 #[must_use]
528 pub fn decls(&self) -> &[MockDecl] {
529 &self.decls
530 }
531
532 #[must_use]
534 pub fn resolve(&self, mut records: Vec<MockCall>) -> Vec<MockCall> {
535 for record in &mut records {
536 record.mock = record
537 .rule
538 .and_then(|rule| self.rule_to_decl.get(rule))
539 .map(|&decl| decl_label(&self.decls[decl], decl));
540 }
541 records
542 }
543
544 pub fn records_for<'r>(
552 &self,
553 name: &str,
554 records: &'r [MockCall],
555 ) -> Result<Vec<&'r MockCall>> {
556 let (index, decl) = self
557 .decls
558 .iter()
559 .enumerate()
560 .find(|(i, d)| decl_label(d, *i) == name)
561 .ok_or_else(|| {
562 let declared: Vec<String> = self
563 .decls
564 .iter()
565 .enumerate()
566 .map(|(i, d)| decl_label(d, i))
567 .collect();
568 Error::Invalid(format!(
569 "eval references unknown mock `{name}` (declared: {})",
570 if declared.is_empty() {
571 "none".to_string()
572 } else {
573 declared.join(", ")
574 }
575 ))
576 })?;
577 if decl.is_mock() {
578 let label = decl_label(decl, index);
579 Ok(records
580 .iter()
581 .filter(|r| r.mock.as_deref() == Some(label.as_str()))
582 .collect())
583 } else {
584 Ok(records
585 .iter()
586 .filter(|r| {
587 decl.matcher
588 .matches_call(r.tool.as_deref(), r.input.as_ref())
589 })
590 .collect())
591 }
592 }
593}
594
595fn decl_label(decl: &MockDecl, index: usize) -> String {
598 decl.name.clone().unwrap_or_else(|| {
599 if decl.is_mock() {
600 format!("mock_{index}")
601 } else {
602 format!("spy_{index}")
603 }
604 })
605}
606
607#[derive(Debug, Clone, Copy)]
611pub struct MockPlan<'a> {
612 pub rules: Option<&'a Value>,
614}
615
616#[derive(Debug, Clone, PartialEq)]
622pub enum AppliedAction<'r> {
623 Deny { message: &'r str },
624 Rewrite { input: &'r Value },
625 Stub { output: &'r str, exit_code: i32 },
626}
627
628impl AppliedAction<'_> {
629 #[must_use]
631 pub fn kind(&self) -> &'static str {
632 match self {
633 AppliedAction::Deny { .. } => "deny",
634 AppliedAction::Rewrite { .. } => "rewrite",
635 AppliedAction::Stub { .. } => "stub",
636 }
637 }
638}
639
640#[must_use]
645pub fn decide<'r>(
646 rules: &'r Value,
647 tool: Option<&str>,
648 input: Option<&Value>,
649) -> Option<(usize, AppliedAction<'r>)> {
650 let list = rules.get("rules")?.as_array()?;
651 for (index, rule) in list.iter().enumerate() {
652 if compiled_rule_matches(rule.get("match")?, tool, input) {
653 return parse_action(rule.get("action")?).map(|action| (index, action));
654 }
655 }
656 None
657}
658
659fn compiled_rule_matches(spec: &Value, tool: Option<&str>, input: Option<&Value>) -> bool {
661 if let Some(want) = spec.get("tool").and_then(Value::as_str) {
662 match tool {
663 Some(name) if name.eq_ignore_ascii_case(want) => {}
664 _ => return false,
665 }
666 }
667 let needs_event = spec.get("event_contains").is_some() || spec.get("event_regex").is_some();
668 if needs_event {
669 let event = event_haystack(tool, input);
670 if let Some(needle) = spec.get("event_contains").and_then(Value::as_str) {
671 if needle.is_empty() || !event.contains(needle) {
672 return false;
673 }
674 }
675 if let Some(pattern) = spec.get("event_regex").and_then(Value::as_str) {
676 match regex::Regex::new(pattern) {
677 Ok(re) if re.is_match(&event) => {}
678 _ => return false,
679 }
680 }
681 }
682 if let Some(preds) = spec.get("input").and_then(Value::as_object) {
683 for (key, pred) in preds {
684 let Some(value) = input.and_then(|i| i.get(key)) else {
685 return false;
686 };
687 let text = coerce_field(value);
688 if let Some(want) = pred.get("equals").and_then(Value::as_str) {
689 if text != want {
690 return false;
691 }
692 }
693 if let Some(needle) = pred.get("contains").and_then(Value::as_str) {
694 if needle.is_empty() || !text.contains(needle) {
695 return false;
696 }
697 }
698 if let Some(pattern) = pred.get("regex").and_then(Value::as_str) {
699 match regex::Regex::new(pattern) {
700 Ok(re) if re.is_match(&text) => {}
701 _ => return false,
702 }
703 }
704 }
705 }
706 true
707}
708
709fn parse_action(action: &Value) -> Option<AppliedAction<'_>> {
710 if let Some(deny) = action.get("deny") {
711 return Some(AppliedAction::Deny {
712 message: deny.get("message")?.as_str()?,
713 });
714 }
715 if let Some(rewrite) = action.get("rewrite") {
716 return Some(AppliedAction::Rewrite {
717 input: rewrite.get("input")?,
718 });
719 }
720 if let Some(stub) = action.get("stub") {
721 return Some(AppliedAction::Stub {
722 output: stub.get("output")?.as_str()?,
723 exit_code: stub
724 .get("exit_code")
725 .and_then(Value::as_i64)
726 .and_then(|v| i32::try_from(v).ok())
727 .unwrap_or(0),
728 });
729 }
730 None
731}
732
733#[must_use]
737pub fn stub_command(output: &str, exit_code: i32) -> String {
738 let quoted = format!("'{}'", output.replace('\'', "'\\''"));
739 let mut command = format!("printf '%s\\n' {quoted}");
740 if exit_code != 0 {
741 command.push_str(&format!("; exit {exit_code}"));
742 }
743 command
744}
745
746#[must_use]
749pub fn where_matches(clause: &BTreeMap<String, FieldPredicate>, input: Option<&Value>) -> bool {
750 for (key, pred) in clause {
751 let Some(value) = input.and_then(|i| i.get(key)) else {
752 return false;
753 };
754 if !pred.matches(&coerce_field(value)) {
755 return false;
756 }
757 }
758 true
759}
760
761#[must_use]
764pub fn decl_matches(matcher: &MockMatch, call: &MockCall) -> bool {
765 matcher.matches_call(call.tool.as_deref(), call.input.as_ref())
766}
767
768pub fn validate_where(clause: &BTreeMap<String, FieldPredicate>, who: &str) -> Result<()> {
773 for (key, pred) in clause {
774 pred.validate(who, key)?;
775 }
776 Ok(())
777}
778
779#[derive(Deserialize)]
785struct SpyLine {
786 event: Value,
788 action: String,
789 #[serde(default)]
790 rule: Option<usize>,
791}
792
793pub fn parse_spy_log(text: &str) -> Result<Vec<MockCall>> {
799 let mut records = Vec::new();
800 for line in text.lines() {
801 let line = line.trim();
802 if line.is_empty() {
803 continue;
804 }
805 let parsed: SpyLine = serde_json::from_str(line).map_err(|e| {
806 Error::provider(
807 "oneharness",
808 format!("invalid spy-log line: {e}; got: {line}"),
809 )
810 })?;
811 records.push(MockCall {
812 tool: event_tool_name(&parsed.event),
813 input: event_tool_input(&parsed.event).cloned(),
814 action: parsed.action,
815 rule: parsed.rule,
816 mock: None,
817 });
818 }
819 Ok(records)
820}
821
822fn event_tool_name(event: &Value) -> Option<String> {
825 for key in ["tool_name", "toolName", "tool"] {
826 if let Some(name) = event.get(key).and_then(Value::as_str) {
827 if !name.is_empty() {
828 return Some(name.to_string());
829 }
830 }
831 }
832 None
833}
834
835fn event_tool_input(event: &Value) -> Option<&Value> {
838 for key in ["tool_input", "toolArgs"] {
839 if let Some(input) = event.get(key) {
840 if input.is_object() {
841 return Some(input);
842 }
843 }
844 }
845 None
846}
847
848fn event_haystack(tool: Option<&str>, input: Option<&Value>) -> String {
851 let mut event = serde_json::Map::new();
852 if let Some(tool) = tool {
853 event.insert("tool_name".into(), json!(tool));
854 }
855 if let Some(input) = input {
856 event.insert("tool_input".into(), input.clone());
857 }
858 Value::Object(event).to_string()
859}
860
861fn coerce_field(value: &Value) -> String {
864 match value.as_str() {
865 Some(s) => s.to_string(),
866 None => value.to_string(),
867 }
868}
869
870fn compile_pattern(pattern: &str, who: &str) -> Result<regex::Regex> {
872 regex::Regex::new(pattern)
873 .map_err(|e| Error::Invalid(format!("{who} is not a valid regex: {e}")))
874}
875
876#[must_use]
880pub fn describe_records(records: &[MockCall]) -> String {
881 const CAP: usize = 5;
882 let mut parts: Vec<String> = records
883 .iter()
884 .take(CAP)
885 .map(|r| {
886 let tool = r.tool.as_deref().unwrap_or("?");
887 let input = r
888 .input
889 .as_ref()
890 .map(|i| match i.get("command").and_then(Value::as_str) {
891 Some(command) => command.to_string(),
892 None => i.to_string(),
893 })
894 .unwrap_or_default();
895 if r.mocked() {
896 format!("{tool}({input}) [{}]", r.action)
897 } else {
898 format!("{tool}({input})")
899 }
900 })
901 .collect();
902 if records.len() > CAP {
903 parts.push(format!("… {} more", records.len() - CAP));
904 }
905 parts.join(", ")
906}
907
908#[cfg(test)]
909mod tests {
910 use super::*;
911
912 fn decl(yaml: &str) -> MockDecl {
913 serde_yaml::from_str(yaml).expect("test decl must parse")
914 }
915
916 #[test]
917 fn parses_stub_deny_rewrite_and_spy_shorthands() {
918 let stub = decl("match: { contains: git push }\nstub: Everything up-to-date\n");
919 assert_eq!(
920 stub.stub.as_ref().unwrap().output(),
921 "Everything up-to-date"
922 );
923 assert_eq!(stub.stub.as_ref().unwrap().exit_code(), 0);
924 assert_eq!(stub.action_kind(), Some("stub"));
925
926 let stub_full = decl("match: { tool: bash }\nstub: { output: boom, exit_code: 2 }\n");
927 assert_eq!(stub_full.stub.as_ref().unwrap().exit_code(), 2);
928
929 let deny = decl("match: { contains: rm -rf }\ndeny: blocked\n");
930 assert_eq!(deny.deny.as_ref().unwrap().message(), "blocked");
931
932 let deny_full = decl("match: { tool: bash }\ndeny: { message: nope }\n");
933 assert_eq!(deny_full.deny.as_ref().unwrap().message(), "nope");
934
935 let rewrite = decl("match: { tool: read }\nrewrite: { file_path: /tmp/fixture }\n");
936 assert_eq!(rewrite.action_kind(), Some("rewrite"));
937
938 let spy = decl("name: git\nmatch: { tool: bash, pattern: \"\\\\bgit\\\\b\" }\n");
939 assert!(!spy.is_mock());
940 assert_eq!(spy.action_kind(), None);
941 }
942
943 #[test]
944 fn typoed_keys_inside_untagged_forms_are_parse_errors() {
945 assert!(serde_yaml::from_str::<MockDecl>(
949 "match: { tool: bash }\nstub: { output: boom, exit_cod: 2 }\n"
950 )
951 .is_err());
952 assert!(serde_yaml::from_str::<MockDecl>(
953 "match: { tool: bash }\ndeny: { mesage: nope }\n"
954 )
955 .is_err());
956 assert!(serde_yaml::from_str::<MockDecl>(
957 "match: { input: { command: { contians: origin } } }\nstub: ok\n"
958 )
959 .is_err());
960 }
961
962 #[test]
963 fn validate_rejects_faults_loudly() {
964 let d = MockDecl {
966 name: None,
967 matcher: MockMatch::default(),
968 stub: None,
969 deny: None,
970 rewrite: None,
971 };
972 assert!(d.validate("mock `x`").is_err());
973 for yaml in [
975 "match: { tool: \"\" }\n",
976 "match: { contains: \"\" }\n",
977 "match: { pattern: \"\" }\n",
978 ] {
979 assert!(decl(yaml).validate("m").is_err(), "{yaml}");
980 }
981 let err = decl("match: { pattern: \"git push(\" }\n")
983 .validate("mock `p`")
984 .unwrap_err();
985 assert!(err.to_string().contains("not a valid regex"), "{err}");
986 assert!(decl("match: { tool: bash }\nstub: x\ndeny: y\n")
988 .validate("m")
989 .is_err());
990 assert!(decl("match: { tool: bash }\nrewrite: 3\n")
992 .validate("m")
993 .is_err());
994 assert!(decl("match: { input: { command: {} } }\n")
996 .validate("m")
997 .is_err());
998 assert!(decl("match: { input: { command: { contains: \"\" } } }\n")
999 .validate("m")
1000 .is_err());
1001 assert!(decl("match: { input: { command: { pattern: \"(\" } } }\n")
1002 .validate("m")
1003 .is_err());
1004 assert!(serde_yaml::from_str::<MockDecl>("match: { tool: bash }\nstubb: x\n").is_err());
1006 }
1007
1008 fn set(yaml: &str) -> MockSet {
1009 let decls: Vec<MockDecl> = serde_yaml::from_str(yaml).expect("decl list must parse");
1010 MockSet::build(&[], &decls, false).expect("set must build")
1011 }
1012
1013 const DECLS: &str = r#"
1014- name: push
1015 match: { tool: bash, pattern: "git push( --force)?\\b" }
1016 stub: Everything up-to-date
1017- name: danger
1018 match: { contains: "rm -rf" }
1019 deny: destructive commands are blocked
1020- name: git
1021 match: { tool: bash, pattern: "\\bgit\\b" }
1022"#;
1023
1024 #[test]
1025 fn compiles_actions_only_in_order_with_oneharness_field_names() {
1026 let set = set(DECLS);
1027 assert!(set.active());
1028 let rules = set.rules().expect("two action decls");
1029 let list = rules["rules"].as_array().unwrap();
1030 assert_eq!(list.len(), 2);
1032 assert_eq!(list[0]["match"]["tool"], "bash");
1033 assert_eq!(list[0]["match"]["event_regex"], "git push( --force)?\\b");
1034 assert_eq!(list[0]["action"]["stub"]["output"], "Everything up-to-date");
1035 assert_eq!(list[0]["action"]["stub"]["exit_code"], 0);
1036 assert_eq!(list[1]["match"]["event_contains"], "rm -rf");
1037 assert_eq!(
1038 list[1]["action"]["deny"]["message"],
1039 "destructive commands are blocked"
1040 );
1041 }
1042
1043 #[test]
1044 fn compiles_input_predicates_with_pattern_renamed_to_regex() {
1045 let set = set(r#"
1046- name: read
1047 match:
1048 tool: read
1049 input:
1050 file_path: { pattern: "secrets" }
1051 mode: r
1052 rewrite: { file_path: /tmp/fixture }
1053"#);
1054 let rule = &set.rules().unwrap()["rules"][0];
1055 assert_eq!(rule["match"]["input"]["file_path"]["regex"], "secrets");
1056 assert_eq!(rule["match"]["input"]["mode"]["equals"], "r");
1057 assert_eq!(
1058 rule["action"]["rewrite"]["input"]["file_path"],
1059 "/tmp/fixture"
1060 );
1061 }
1062
1063 #[test]
1064 fn spy_only_set_has_no_rules_but_is_active() {
1065 let set = set("- name: git\n match: { tool: bash }\n");
1066 assert!(set.rules().is_none());
1067 assert!(set.active());
1068 let bare = MockSet::build(&[], &[], true).unwrap();
1070 assert!(bare.active());
1071 assert!(!MockSet::build(&[], &[], false).unwrap().active());
1072 }
1073
1074 #[test]
1075 fn build_rejects_duplicate_names_across_shared_and_case() {
1076 let shared: Vec<MockDecl> =
1077 serde_yaml::from_str("- name: push\n match: { tool: bash }\n stub: x\n").unwrap();
1078 let case: Vec<MockDecl> =
1079 serde_yaml::from_str("- name: push\n match: { tool: bash }\n").unwrap();
1080 let err = MockSet::build(&shared, &case, false).unwrap_err();
1081 assert!(err.to_string().contains("duplicate mock name"), "{err}");
1082 }
1083
1084 fn call(tool: &str, command: &str, action: &str, rule: Option<usize>) -> MockCall {
1085 MockCall {
1086 tool: Some(tool.into()),
1087 input: Some(json!({ "command": command })),
1088 action: action.into(),
1089 rule,
1090 mock: None,
1091 }
1092 }
1093
1094 #[test]
1095 fn resolve_fills_mock_names_and_records_for_binds_by_name() {
1096 let set = set(DECLS);
1097 let records = set.resolve(vec![
1098 call("Bash", "git push origin", "stub", Some(0)),
1099 call("Bash", "git status", "allow", None),
1100 call("Bash", "rm -rf /", "deny", Some(1)),
1101 call("Read", "n/a", "allow", None),
1102 ]);
1103 assert_eq!(records[0].mock.as_deref(), Some("push"));
1104 assert!(records[1].mock.is_none());
1105 assert_eq!(records[2].mock.as_deref(), Some("danger"));
1106
1107 let push = set.records_for("push", &records).unwrap();
1109 assert_eq!(push.len(), 1);
1110 assert!(push[0].mocked());
1111 let git = set.records_for("git", &records).unwrap();
1114 assert_eq!(git.len(), 2);
1115 let err = set.records_for("psuh", &records).unwrap_err();
1117 assert!(err.to_string().contains("unknown mock `psuh`"), "{err}");
1118 assert!(err.to_string().contains("push, danger, git"), "{err}");
1119 }
1120
1121 #[test]
1122 fn decide_first_match_wins_and_mirrors_matcher_semantics() {
1123 let set = set(DECLS);
1124 let rules = set.rules().unwrap();
1125 for command in ["git push origin", "git push --force origin"] {
1127 let (rule, action) =
1128 decide(rules, Some("Bash"), Some(&json!({ "command": command }))).unwrap();
1129 assert_eq!(rule, 0, "{command}");
1130 assert!(
1131 matches!(action, AppliedAction::Stub { output, exit_code: 0 }
1132 if output == "Everything up-to-date")
1133 );
1134 }
1135 let (rule, action) = decide(
1137 rules,
1138 Some("Bash"),
1139 Some(&json!({ "command": "rm -rf /tmp" })),
1140 )
1141 .unwrap();
1142 assert_eq!(rule, 1);
1143 assert_eq!(action.kind(), "deny");
1144 assert!(decide(
1146 rules,
1147 Some("Bash"),
1148 Some(&json!({ "command": "git pushy" }))
1149 )
1150 .is_none());
1151 assert!(decide(rules, Some("Read"), Some(&json!({ "command": "git push" }))).is_none());
1152 assert!(decide(rules, None, Some(&json!({ "command": "git push" }))).is_none());
1154 }
1155
1156 #[test]
1157 fn decide_applies_input_predicates_and_coerces_non_strings() {
1158 let set = set(r#"
1159- match:
1160 input:
1161 file_path: { contains: secrets }
1162 depth: { equals: "3" }
1163 deny: no secrets
1164"#);
1165 let rules = set.rules().unwrap();
1166 let input = json!({ "file_path": "/etc/secrets.json", "depth": 3 });
1168 assert!(decide(rules, Some("read"), Some(&input)).is_some());
1169 let input = json!({ "file_path": "/etc/secrets.json" });
1171 assert!(decide(rules, Some("read"), Some(&input)).is_none());
1172 }
1173
1174 #[test]
1175 fn stub_command_quotes_posix_safely() {
1176 assert_eq!(stub_command("clean", 0), "printf '%s\\n' 'clean'");
1177 assert_eq!(
1178 stub_command("it's done", 2),
1179 "printf '%s\\n' 'it'\\''s done'; exit 2"
1180 );
1181 }
1182
1183 #[test]
1184 fn where_matches_field_predicates() {
1185 let clause: BTreeMap<String, FieldPredicate> =
1186 serde_yaml::from_str("command: { contains: \"--force\" }\n").unwrap();
1187 assert!(where_matches(
1188 &clause,
1189 Some(&json!({ "command": "git push --force" }))
1190 ));
1191 assert!(!where_matches(
1192 &clause,
1193 Some(&json!({ "command": "git push" }))
1194 ));
1195 assert!(!where_matches(&clause, None));
1196 let exact: BTreeMap<String, FieldPredicate> =
1198 serde_yaml::from_str("command: git status\n").unwrap();
1199 assert!(where_matches(
1200 &exact,
1201 Some(&json!({ "command": "git status" }))
1202 ));
1203 assert!(!where_matches(
1204 &exact,
1205 Some(&json!({ "command": "git status -s" }))
1206 ));
1207 assert!(where_matches(&BTreeMap::new(), None));
1209 }
1210
1211 #[test]
1212 fn parse_spy_log_reads_oneharness_lines_and_rejects_garbage() {
1213 let log = concat!(
1214 r#"{"harness":"claude-code","event":{"tool_name":"Bash","tool_input":{"command":"git push"}},"action":"stub","rule":0}"#,
1215 "\n\n",
1216 r#"{"harness":"claude-code","event":{"toolName":"shell","toolArgs":{"command":"ls"}},"action":"allow","rule":null}"#,
1217 "\n",
1218 );
1219 let records = parse_spy_log(log).unwrap();
1220 assert_eq!(records.len(), 2);
1221 assert_eq!(records[0].tool.as_deref(), Some("Bash"));
1222 assert_eq!(records[0].input.as_ref().unwrap()["command"], "git push");
1223 assert_eq!(records[0].action, "stub");
1224 assert_eq!(records[0].rule, Some(0));
1225 assert!(records[0].mocked());
1226 assert_eq!(records[1].tool.as_deref(), Some("shell"));
1228 assert_eq!(records[1].rule, None);
1229 assert!(!records[1].mocked());
1230 assert!(parse_spy_log("{\"event\":").is_err());
1232 }
1233
1234 #[test]
1235 fn field_predicate_spec_forms_all_hold_locally() {
1236 let clause: BTreeMap<String, FieldPredicate> = serde_yaml::from_str(
1238 "command: { equals: \"git push\", contains: push, pattern: \"^git\" }\n",
1239 )
1240 .unwrap();
1241 assert!(where_matches(
1242 &clause,
1243 Some(&json!({ "command": "git push" }))
1244 ));
1245 assert!(!where_matches(
1247 &clause,
1248 Some(&json!({ "command": "git pushx" }))
1249 ));
1250 let pat_only: BTreeMap<String, FieldPredicate> =
1251 serde_yaml::from_str("command: { pattern: \"^git\" }\n").unwrap();
1252 assert!(!where_matches(
1253 &pat_only,
1254 Some(&json!({ "command": "use git" }))
1255 ));
1256 }
1257
1258 #[test]
1259 fn matches_call_contains_matches_over_event_haystack() {
1260 let matcher: MockMatch = serde_yaml::from_str("contains: \"git push\"\n").unwrap();
1261 assert!(matcher.matches_call(Some("Bash"), Some(&json!({ "command": "git push" }))));
1262 assert!(!matcher.matches_call(Some("Bash"), Some(&json!({ "command": "ls" }))));
1263 }
1264
1265 #[test]
1266 fn unnamed_decls_get_positional_labels_and_empty_set_reports_none() {
1267 let decls: Vec<MockDecl> =
1269 serde_yaml::from_str("- match: { tool: bash }\n stub: x\n- match: { tool: read }\n")
1270 .unwrap();
1271 let set = MockSet::build(&[], &decls, false).unwrap();
1272 let records = set.resolve(vec![call("bash", "ls", "stub", Some(0))]);
1273 assert_eq!(records[0].mock.as_deref(), Some("mock_0"));
1274 assert_eq!(set.records_for("spy_1", &records).unwrap().len(), 0);
1275 assert_eq!(set.decls().len(), 2);
1276 let empty = MockSet::build(&[], &[], true).unwrap();
1278 let err = empty.records_for("ghost", &[]).unwrap_err();
1279 assert!(err.to_string().contains("declared: none"), "{err}");
1280 }
1281
1282 #[test]
1283 fn decide_applies_rewrite_actions_and_reports_kind() {
1284 let set = set(
1285 "- match: { tool: read, input: { file_path: { pattern: \"secrets\" } } }\n rewrite: { file_path: /tmp/fixture }\n",
1286 );
1287 let rules = set.rules().unwrap();
1288 let input = json!({ "file_path": "/etc/secrets.json" });
1289 let (rule, action) = decide(rules, Some("read"), Some(&input)).unwrap();
1290 assert_eq!(rule, 0);
1291 assert_eq!(action.kind(), "rewrite");
1292 assert!(matches!(action, AppliedAction::Rewrite { input }
1293 if input["file_path"] == "/tmp/fixture"));
1294 assert!(decide(
1297 rules,
1298 Some("read"),
1299 Some(&json!({ "file_path": "/etc/ok" }))
1300 )
1301 .is_none());
1302 }
1303
1304 #[test]
1305 fn describe_records_caps_and_shows_verdicts() {
1306 let mut records: Vec<MockCall> = (0..7)
1307 .map(|i| call("bash", &format!("cmd{i}"), "allow", None))
1308 .collect();
1309 records[0] = call("bash", "git push", "deny", Some(0));
1310 records[1] = MockCall {
1312 tool: Some("read".into()),
1313 input: Some(json!({ "file_path": "/x" })),
1314 action: "allow".into(),
1315 rule: None,
1316 mock: None,
1317 };
1318 let text = describe_records(&records);
1319 assert!(text.contains("bash(git push) [deny]"), "{text}");
1320 assert!(text.contains("read({\"file_path\":\"/x\"})"), "{text}");
1321 assert!(text.contains("… 2 more"), "{text}");
1322 let bare = MockCall {
1324 tool: None,
1325 input: None,
1326 action: "allow".into(),
1327 rule: None,
1328 mock: None,
1329 };
1330 assert_eq!(describe_records(&[bare]), "?()");
1331 }
1332
1333 #[test]
1334 fn matches_call_composes_all_criteria() {
1335 let matcher: MockMatch = serde_yaml::from_str(
1336 "tool: bash\npattern: \"git (status|diff)\"\ninput: { command: { contains: git } }\n",
1337 )
1338 .unwrap();
1339 assert!(matcher.matches_call(Some("Bash"), Some(&json!({ "command": "git status" }))));
1340 assert!(!matcher.matches_call(Some("Bash"), Some(&json!({ "command": "git push" }))));
1341 assert!(!matcher.matches_call(Some("read"), Some(&json!({ "command": "git status" }))));
1342 assert!(!matcher.matches_call(None, Some(&json!({ "command": "git status" }))));
1343 }
1344}