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)]
39#[serde(untagged)]
40pub enum FieldPredicate {
41 Equals(String),
43 Spec {
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 equals: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 contains: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pattern: Option<String>,
52 },
53}
54
55impl FieldPredicate {
56 fn validate(&self, who: &str, key: &str) -> Result<()> {
58 match self {
59 FieldPredicate::Equals(_) => Ok(()),
60 FieldPredicate::Spec {
61 equals,
62 contains,
63 pattern,
64 } => {
65 if equals.is_none() && contains.is_none() && pattern.is_none() {
66 return Err(Error::Invalid(format!(
67 "{who}: `input.{key}` needs one of `equals`/`contains`/`pattern`"
68 )));
69 }
70 if contains.as_deref() == Some("") || pattern.as_deref() == Some("") {
71 return Err(Error::Invalid(format!(
72 "{who}: empty `contains`/`pattern` in `input.{key}` would match everything"
73 )));
74 }
75 if let Some(pattern) = pattern {
76 compile_pattern(pattern, &format!("{who}: `input.{key}.pattern`"))?;
77 }
78 Ok(())
79 }
80 }
81 }
82
83 fn matches(&self, value: &str) -> bool {
85 match self {
86 FieldPredicate::Equals(want) => value == want,
87 FieldPredicate::Spec {
88 equals,
89 contains,
90 pattern,
91 } => {
92 if let Some(want) = equals {
93 if value != want {
94 return false;
95 }
96 }
97 if let Some(needle) = contains {
98 if needle.is_empty() || !value.contains(needle.as_str()) {
99 return false;
100 }
101 }
102 if let Some(pattern) = pattern {
103 match regex::Regex::new(pattern) {
106 Ok(re) if re.is_match(value) => {}
107 _ => return false,
108 }
109 }
110 true
111 }
112 }
113 }
114
115 fn to_oneharness(&self) -> Value {
117 match self {
118 FieldPredicate::Equals(want) => json!({ "equals": want }),
119 FieldPredicate::Spec {
120 equals,
121 contains,
122 pattern,
123 } => {
124 let mut spec = serde_json::Map::new();
125 if let Some(v) = equals {
126 spec.insert("equals".into(), json!(v));
127 }
128 if let Some(v) = contains {
129 spec.insert("contains".into(), json!(v));
130 }
131 if let Some(v) = pattern {
132 spec.insert("regex".into(), json!(v));
133 }
134 Value::Object(spec)
135 }
136 }
137 }
138}
139
140#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(deny_unknown_fields)]
144pub struct MockMatch {
145 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub tool: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub contains: Option<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub pattern: Option<String>,
159 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
164 pub input: BTreeMap<String, FieldPredicate>,
165}
166
167impl MockMatch {
168 fn validate(&self, who: &str) -> Result<()> {
170 for (field, value) in [
171 ("tool", &self.tool),
172 ("contains", &self.contains),
173 ("pattern", &self.pattern),
174 ] {
175 if value.as_deref() == Some("") {
176 return Err(Error::Invalid(format!(
177 "{who}: empty `match.{field}` would match everything"
178 )));
179 }
180 }
181 if let Some(pattern) = &self.pattern {
182 compile_pattern(pattern, &format!("{who}: `match.pattern`"))?;
183 }
184 for (key, pred) in &self.input {
185 pred.validate(who, key)?;
186 }
187 if self.tool.is_none()
188 && self.contains.is_none()
189 && self.pattern.is_none()
190 && self.input.is_empty()
191 {
192 return Err(Error::Invalid(format!(
193 "{who}: `match` needs at least one of `tool`, `contains`, `pattern`, or `input`"
194 )));
195 }
196 Ok(())
197 }
198
199 #[must_use]
204 pub fn matches_call(&self, tool: Option<&str>, input: Option<&Value>) -> bool {
205 if let Some(want) = &self.tool {
206 match tool {
207 Some(name) if name.eq_ignore_ascii_case(want) => {}
208 _ => return false,
209 }
210 }
211 if self.contains.is_some() || self.pattern.is_some() {
212 let event = event_haystack(tool, input);
213 if let Some(needle) = &self.contains {
214 if needle.is_empty() || !event.contains(needle.as_str()) {
215 return false;
216 }
217 }
218 if let Some(pattern) = &self.pattern {
219 match regex::Regex::new(pattern) {
220 Ok(re) if re.is_match(&event) => {}
221 _ => return false,
222 }
223 }
224 }
225 where_matches(&self.input, input)
226 }
227
228 fn to_oneharness(&self) -> Value {
230 let mut spec = serde_json::Map::new();
231 if let Some(tool) = &self.tool {
232 spec.insert("tool".into(), json!(tool));
233 }
234 if let Some(contains) = &self.contains {
235 spec.insert("event_contains".into(), json!(contains));
236 }
237 if let Some(pattern) = &self.pattern {
238 spec.insert("event_regex".into(), json!(pattern));
239 }
240 if !self.input.is_empty() {
241 let input: serde_json::Map<String, Value> = self
242 .input
243 .iter()
244 .map(|(k, p)| (k.clone(), p.to_oneharness()))
245 .collect();
246 spec.insert("input".into(), Value::Object(input));
247 }
248 Value::Object(spec)
249 }
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(untagged)]
256pub enum StubSpec {
257 Text(String),
259 Full {
260 output: String,
261 #[serde(default)]
263 exit_code: i32,
264 },
265}
266
267impl StubSpec {
268 #[must_use]
270 pub fn output(&self) -> &str {
271 match self {
272 StubSpec::Text(output) | StubSpec::Full { output, .. } => output,
273 }
274 }
275
276 #[must_use]
278 pub fn exit_code(&self) -> i32 {
279 match self {
280 StubSpec::Text(_) => 0,
281 StubSpec::Full { exit_code, .. } => *exit_code,
282 }
283 }
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(untagged)]
290pub enum DenySpec {
291 Text(String),
292 Full { message: String },
293}
294
295impl DenySpec {
296 #[must_use]
298 pub fn message(&self) -> &str {
299 match self {
300 DenySpec::Text(message) | DenySpec::Full { message } => message,
301 }
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
310#[serde(deny_unknown_fields)]
311pub struct MockDecl {
312 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub name: Option<String>,
317 #[serde(rename = "match")]
318 pub matcher: MockMatch,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub stub: Option<StubSpec>,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub deny: Option<DenySpec>,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub rewrite: Option<Value>,
331}
332
333impl MockDecl {
334 #[must_use]
336 pub fn is_mock(&self) -> bool {
337 self.stub.is_some() || self.deny.is_some() || self.rewrite.is_some()
338 }
339
340 pub fn validate(&self, who: &str) -> Result<()> {
346 if self.name.as_deref() == Some("") {
347 return Err(Error::Invalid(format!("{who}: `name` must not be empty")));
348 }
349 self.matcher.validate(who)?;
350 let actions = usize::from(self.stub.is_some())
351 + usize::from(self.deny.is_some())
352 + usize::from(self.rewrite.is_some());
353 if actions > 1 {
354 return Err(Error::Invalid(format!(
355 "{who}: declare at most one of `stub`/`deny`/`rewrite` (omit all three for a spy)"
356 )));
357 }
358 if let Some(rewrite) = &self.rewrite {
359 if !rewrite.is_object() {
360 return Err(Error::Invalid(format!(
361 "{who}: `rewrite` must be a JSON object (the substituted tool arguments)"
362 )));
363 }
364 }
365 if let Some(StubSpec::Text(text)) = &self.stub {
366 if text.is_empty() {
367 return Err(Error::Invalid(format!(
368 "{who}: `stub` output must not be empty (use `deny` to block a call)"
369 )));
370 }
371 }
372 Ok(())
373 }
374
375 fn action_json(&self) -> Option<Value> {
377 if let Some(stub) = &self.stub {
378 return Some(json!({
379 "stub": { "output": stub.output(), "exit_code": stub.exit_code() }
380 }));
381 }
382 if let Some(deny) = &self.deny {
383 return Some(json!({ "deny": { "message": deny.message() } }));
384 }
385 self.rewrite
386 .as_ref()
387 .map(|input| json!({ "rewrite": { "input": input } }))
388 }
389
390 #[must_use]
392 pub fn action_kind(&self) -> Option<&'static str> {
393 if self.stub.is_some() {
394 Some("stub")
395 } else if self.deny.is_some() {
396 Some("deny")
397 } else if self.rewrite.is_some() {
398 Some("rewrite")
399 } else {
400 None
401 }
402 }
403}
404
405#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
411pub struct MockCall {
412 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub tool: Option<String>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub input: Option<Value>,
418 pub action: String,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub rule: Option<usize>,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub mock: Option<String>,
429}
430
431impl MockCall {
432 #[must_use]
434 pub fn mocked(&self) -> bool {
435 self.action != "allow"
436 }
437}
438
439#[derive(Debug, Clone, Default)]
444pub struct MockSet {
445 decls: Vec<MockDecl>,
446 rules: Option<Value>,
449 rule_to_decl: Vec<usize>,
451 spy_requested: bool,
454}
455
456impl MockSet {
457 pub fn build(shared: &[MockDecl], case: &[MockDecl], spy_requested: bool) -> Result<MockSet> {
464 let decls: Vec<MockDecl> = shared.iter().chain(case.iter()).cloned().collect();
465 let mut seen = std::collections::BTreeSet::new();
466 for (i, decl) in decls.iter().enumerate() {
467 decl.validate(&format!("mock `{}`", decl_label(decl, i)))?;
468 if let Some(name) = &decl.name {
469 if !seen.insert(name.clone()) {
470 return Err(Error::Invalid(format!(
471 "duplicate mock name `{name}` (names must be unique across --mocks and the case)"
472 )));
473 }
474 }
475 }
476 let mut rules = Vec::new();
477 let mut rule_to_decl = Vec::new();
478 for (i, decl) in decls.iter().enumerate() {
479 if let Some(action) = decl.action_json() {
480 rules.push(json!({ "match": decl.matcher.to_oneharness(), "action": action }));
481 rule_to_decl.push(i);
482 }
483 }
484 Ok(MockSet {
485 decls,
486 rules: (!rules.is_empty()).then(|| json!({ "rules": rules })),
487 rule_to_decl,
488 spy_requested,
489 })
490 }
491
492 #[must_use]
494 pub fn active(&self) -> bool {
495 self.spy_requested || !self.decls.is_empty()
496 }
497
498 #[must_use]
500 pub fn rules(&self) -> Option<&Value> {
501 self.rules.as_ref()
502 }
503
504 #[must_use]
506 pub fn decls(&self) -> &[MockDecl] {
507 &self.decls
508 }
509
510 #[must_use]
512 pub fn resolve(&self, mut records: Vec<MockCall>) -> Vec<MockCall> {
513 for record in &mut records {
514 record.mock = record
515 .rule
516 .and_then(|rule| self.rule_to_decl.get(rule))
517 .map(|&decl| decl_label(&self.decls[decl], decl));
518 }
519 records
520 }
521
522 pub fn records_for<'r>(
530 &self,
531 name: &str,
532 records: &'r [MockCall],
533 ) -> Result<Vec<&'r MockCall>> {
534 let (index, decl) = self
535 .decls
536 .iter()
537 .enumerate()
538 .find(|(i, d)| decl_label(d, *i) == name)
539 .ok_or_else(|| {
540 let declared: Vec<String> = self
541 .decls
542 .iter()
543 .enumerate()
544 .map(|(i, d)| decl_label(d, i))
545 .collect();
546 Error::Invalid(format!(
547 "eval references unknown mock `{name}` (declared: {})",
548 if declared.is_empty() {
549 "none".to_string()
550 } else {
551 declared.join(", ")
552 }
553 ))
554 })?;
555 if decl.is_mock() {
556 let label = decl_label(decl, index);
557 Ok(records
558 .iter()
559 .filter(|r| r.mock.as_deref() == Some(label.as_str()))
560 .collect())
561 } else {
562 Ok(records
563 .iter()
564 .filter(|r| {
565 decl.matcher
566 .matches_call(r.tool.as_deref(), r.input.as_ref())
567 })
568 .collect())
569 }
570 }
571}
572
573fn decl_label(decl: &MockDecl, index: usize) -> String {
576 decl.name.clone().unwrap_or_else(|| {
577 if decl.is_mock() {
578 format!("mock_{index}")
579 } else {
580 format!("spy_{index}")
581 }
582 })
583}
584
585#[derive(Debug, Clone, Copy)]
589pub struct MockPlan<'a> {
590 pub rules: Option<&'a Value>,
592}
593
594#[derive(Debug, Clone, PartialEq)]
600pub enum AppliedAction<'r> {
601 Deny { message: &'r str },
602 Rewrite { input: &'r Value },
603 Stub { output: &'r str, exit_code: i32 },
604}
605
606impl AppliedAction<'_> {
607 #[must_use]
609 pub fn kind(&self) -> &'static str {
610 match self {
611 AppliedAction::Deny { .. } => "deny",
612 AppliedAction::Rewrite { .. } => "rewrite",
613 AppliedAction::Stub { .. } => "stub",
614 }
615 }
616}
617
618#[must_use]
623pub fn decide<'r>(
624 rules: &'r Value,
625 tool: Option<&str>,
626 input: Option<&Value>,
627) -> Option<(usize, AppliedAction<'r>)> {
628 let list = rules.get("rules")?.as_array()?;
629 for (index, rule) in list.iter().enumerate() {
630 if compiled_rule_matches(rule.get("match")?, tool, input) {
631 return parse_action(rule.get("action")?).map(|action| (index, action));
632 }
633 }
634 None
635}
636
637fn compiled_rule_matches(spec: &Value, tool: Option<&str>, input: Option<&Value>) -> bool {
639 if let Some(want) = spec.get("tool").and_then(Value::as_str) {
640 match tool {
641 Some(name) if name.eq_ignore_ascii_case(want) => {}
642 _ => return false,
643 }
644 }
645 let needs_event = spec.get("event_contains").is_some() || spec.get("event_regex").is_some();
646 if needs_event {
647 let event = event_haystack(tool, input);
648 if let Some(needle) = spec.get("event_contains").and_then(Value::as_str) {
649 if needle.is_empty() || !event.contains(needle) {
650 return false;
651 }
652 }
653 if let Some(pattern) = spec.get("event_regex").and_then(Value::as_str) {
654 match regex::Regex::new(pattern) {
655 Ok(re) if re.is_match(&event) => {}
656 _ => return false,
657 }
658 }
659 }
660 if let Some(preds) = spec.get("input").and_then(Value::as_object) {
661 for (key, pred) in preds {
662 let Some(value) = input.and_then(|i| i.get(key)) else {
663 return false;
664 };
665 let text = coerce_field(value);
666 if let Some(want) = pred.get("equals").and_then(Value::as_str) {
667 if text != want {
668 return false;
669 }
670 }
671 if let Some(needle) = pred.get("contains").and_then(Value::as_str) {
672 if needle.is_empty() || !text.contains(needle) {
673 return false;
674 }
675 }
676 if let Some(pattern) = pred.get("regex").and_then(Value::as_str) {
677 match regex::Regex::new(pattern) {
678 Ok(re) if re.is_match(&text) => {}
679 _ => return false,
680 }
681 }
682 }
683 }
684 true
685}
686
687fn parse_action(action: &Value) -> Option<AppliedAction<'_>> {
688 if let Some(deny) = action.get("deny") {
689 return Some(AppliedAction::Deny {
690 message: deny.get("message")?.as_str()?,
691 });
692 }
693 if let Some(rewrite) = action.get("rewrite") {
694 return Some(AppliedAction::Rewrite {
695 input: rewrite.get("input")?,
696 });
697 }
698 if let Some(stub) = action.get("stub") {
699 return Some(AppliedAction::Stub {
700 output: stub.get("output")?.as_str()?,
701 exit_code: stub
702 .get("exit_code")
703 .and_then(Value::as_i64)
704 .and_then(|v| i32::try_from(v).ok())
705 .unwrap_or(0),
706 });
707 }
708 None
709}
710
711#[must_use]
715pub fn stub_command(output: &str, exit_code: i32) -> String {
716 let quoted = format!("'{}'", output.replace('\'', "'\\''"));
717 let mut command = format!("printf '%s\\n' {quoted}");
718 if exit_code != 0 {
719 command.push_str(&format!("; exit {exit_code}"));
720 }
721 command
722}
723
724#[must_use]
727pub fn where_matches(clause: &BTreeMap<String, FieldPredicate>, input: Option<&Value>) -> bool {
728 for (key, pred) in clause {
729 let Some(value) = input.and_then(|i| i.get(key)) else {
730 return false;
731 };
732 if !pred.matches(&coerce_field(value)) {
733 return false;
734 }
735 }
736 true
737}
738
739#[must_use]
742pub fn decl_matches(matcher: &MockMatch, call: &MockCall) -> bool {
743 matcher.matches_call(call.tool.as_deref(), call.input.as_ref())
744}
745
746pub fn validate_where(clause: &BTreeMap<String, FieldPredicate>, who: &str) -> Result<()> {
751 for (key, pred) in clause {
752 pred.validate(who, key)?;
753 }
754 Ok(())
755}
756
757#[derive(Deserialize)]
763struct SpyLine {
764 event: Value,
766 action: String,
767 #[serde(default)]
768 rule: Option<usize>,
769}
770
771pub fn parse_spy_log(text: &str) -> Result<Vec<MockCall>> {
777 let mut records = Vec::new();
778 for line in text.lines() {
779 let line = line.trim();
780 if line.is_empty() {
781 continue;
782 }
783 let parsed: SpyLine = serde_json::from_str(line).map_err(|e| {
784 Error::provider(
785 "oneharness",
786 format!("invalid spy-log line: {e}; got: {line}"),
787 )
788 })?;
789 records.push(MockCall {
790 tool: event_tool_name(&parsed.event),
791 input: event_tool_input(&parsed.event).cloned(),
792 action: parsed.action,
793 rule: parsed.rule,
794 mock: None,
795 });
796 }
797 Ok(records)
798}
799
800fn event_tool_name(event: &Value) -> Option<String> {
803 for key in ["tool_name", "toolName", "tool"] {
804 if let Some(name) = event.get(key).and_then(Value::as_str) {
805 if !name.is_empty() {
806 return Some(name.to_string());
807 }
808 }
809 }
810 None
811}
812
813fn event_tool_input(event: &Value) -> Option<&Value> {
816 for key in ["tool_input", "toolArgs"] {
817 if let Some(input) = event.get(key) {
818 if input.is_object() {
819 return Some(input);
820 }
821 }
822 }
823 None
824}
825
826fn event_haystack(tool: Option<&str>, input: Option<&Value>) -> String {
829 let mut event = serde_json::Map::new();
830 if let Some(tool) = tool {
831 event.insert("tool_name".into(), json!(tool));
832 }
833 if let Some(input) = input {
834 event.insert("tool_input".into(), input.clone());
835 }
836 Value::Object(event).to_string()
837}
838
839fn coerce_field(value: &Value) -> String {
842 match value.as_str() {
843 Some(s) => s.to_string(),
844 None => value.to_string(),
845 }
846}
847
848fn compile_pattern(pattern: &str, who: &str) -> Result<regex::Regex> {
850 regex::Regex::new(pattern)
851 .map_err(|e| Error::Invalid(format!("{who} is not a valid regex: {e}")))
852}
853
854#[must_use]
858pub fn describe_records(records: &[MockCall]) -> String {
859 const CAP: usize = 5;
860 let mut parts: Vec<String> = records
861 .iter()
862 .take(CAP)
863 .map(|r| {
864 let tool = r.tool.as_deref().unwrap_or("?");
865 let input = r
866 .input
867 .as_ref()
868 .map(|i| match i.get("command").and_then(Value::as_str) {
869 Some(command) => command.to_string(),
870 None => i.to_string(),
871 })
872 .unwrap_or_default();
873 if r.mocked() {
874 format!("{tool}({input}) [{}]", r.action)
875 } else {
876 format!("{tool}({input})")
877 }
878 })
879 .collect();
880 if records.len() > CAP {
881 parts.push(format!("… {} more", records.len() - CAP));
882 }
883 parts.join(", ")
884}
885
886#[cfg(test)]
887mod tests {
888 use super::*;
889
890 fn decl(yaml: &str) -> MockDecl {
891 serde_yaml::from_str(yaml).expect("test decl must parse")
892 }
893
894 #[test]
895 fn parses_stub_deny_rewrite_and_spy_shorthands() {
896 let stub = decl("match: { contains: git push }\nstub: Everything up-to-date\n");
897 assert_eq!(
898 stub.stub.as_ref().unwrap().output(),
899 "Everything up-to-date"
900 );
901 assert_eq!(stub.stub.as_ref().unwrap().exit_code(), 0);
902 assert_eq!(stub.action_kind(), Some("stub"));
903
904 let stub_full = decl("match: { tool: bash }\nstub: { output: boom, exit_code: 2 }\n");
905 assert_eq!(stub_full.stub.as_ref().unwrap().exit_code(), 2);
906
907 let deny = decl("match: { contains: rm -rf }\ndeny: blocked\n");
908 assert_eq!(deny.deny.as_ref().unwrap().message(), "blocked");
909
910 let deny_full = decl("match: { tool: bash }\ndeny: { message: nope }\n");
911 assert_eq!(deny_full.deny.as_ref().unwrap().message(), "nope");
912
913 let rewrite = decl("match: { tool: read }\nrewrite: { file_path: /tmp/fixture }\n");
914 assert_eq!(rewrite.action_kind(), Some("rewrite"));
915
916 let spy = decl("name: git\nmatch: { tool: bash, pattern: \"\\\\bgit\\\\b\" }\n");
917 assert!(!spy.is_mock());
918 assert_eq!(spy.action_kind(), None);
919 }
920
921 #[test]
922 fn validate_rejects_faults_loudly() {
923 let d = MockDecl {
925 name: None,
926 matcher: MockMatch::default(),
927 stub: None,
928 deny: None,
929 rewrite: None,
930 };
931 assert!(d.validate("mock `x`").is_err());
932 for yaml in [
934 "match: { tool: \"\" }\n",
935 "match: { contains: \"\" }\n",
936 "match: { pattern: \"\" }\n",
937 ] {
938 assert!(decl(yaml).validate("m").is_err(), "{yaml}");
939 }
940 let err = decl("match: { pattern: \"git push(\" }\n")
942 .validate("mock `p`")
943 .unwrap_err();
944 assert!(err.to_string().contains("not a valid regex"), "{err}");
945 assert!(decl("match: { tool: bash }\nstub: x\ndeny: y\n")
947 .validate("m")
948 .is_err());
949 assert!(decl("match: { tool: bash }\nrewrite: 3\n")
951 .validate("m")
952 .is_err());
953 assert!(decl("match: { input: { command: {} } }\n")
955 .validate("m")
956 .is_err());
957 assert!(decl("match: { input: { command: { contains: \"\" } } }\n")
958 .validate("m")
959 .is_err());
960 assert!(decl("match: { input: { command: { pattern: \"(\" } } }\n")
961 .validate("m")
962 .is_err());
963 assert!(serde_yaml::from_str::<MockDecl>("match: { tool: bash }\nstubb: x\n").is_err());
965 }
966
967 fn set(yaml: &str) -> MockSet {
968 let decls: Vec<MockDecl> = serde_yaml::from_str(yaml).expect("decl list must parse");
969 MockSet::build(&[], &decls, false).expect("set must build")
970 }
971
972 const DECLS: &str = r#"
973- name: push
974 match: { tool: bash, pattern: "git push( --force)?\\b" }
975 stub: Everything up-to-date
976- name: danger
977 match: { contains: "rm -rf" }
978 deny: destructive commands are blocked
979- name: git
980 match: { tool: bash, pattern: "\\bgit\\b" }
981"#;
982
983 #[test]
984 fn compiles_actions_only_in_order_with_oneharness_field_names() {
985 let set = set(DECLS);
986 assert!(set.active());
987 let rules = set.rules().expect("two action decls");
988 let list = rules["rules"].as_array().unwrap();
989 assert_eq!(list.len(), 2);
991 assert_eq!(list[0]["match"]["tool"], "bash");
992 assert_eq!(list[0]["match"]["event_regex"], "git push( --force)?\\b");
993 assert_eq!(list[0]["action"]["stub"]["output"], "Everything up-to-date");
994 assert_eq!(list[0]["action"]["stub"]["exit_code"], 0);
995 assert_eq!(list[1]["match"]["event_contains"], "rm -rf");
996 assert_eq!(
997 list[1]["action"]["deny"]["message"],
998 "destructive commands are blocked"
999 );
1000 }
1001
1002 #[test]
1003 fn compiles_input_predicates_with_pattern_renamed_to_regex() {
1004 let set = set(r#"
1005- name: read
1006 match:
1007 tool: read
1008 input:
1009 file_path: { pattern: "secrets" }
1010 mode: r
1011 rewrite: { file_path: /tmp/fixture }
1012"#);
1013 let rule = &set.rules().unwrap()["rules"][0];
1014 assert_eq!(rule["match"]["input"]["file_path"]["regex"], "secrets");
1015 assert_eq!(rule["match"]["input"]["mode"]["equals"], "r");
1016 assert_eq!(
1017 rule["action"]["rewrite"]["input"]["file_path"],
1018 "/tmp/fixture"
1019 );
1020 }
1021
1022 #[test]
1023 fn spy_only_set_has_no_rules_but_is_active() {
1024 let set = set("- name: git\n match: { tool: bash }\n");
1025 assert!(set.rules().is_none());
1026 assert!(set.active());
1027 let bare = MockSet::build(&[], &[], true).unwrap();
1029 assert!(bare.active());
1030 assert!(!MockSet::build(&[], &[], false).unwrap().active());
1031 }
1032
1033 #[test]
1034 fn build_rejects_duplicate_names_across_shared_and_case() {
1035 let shared: Vec<MockDecl> =
1036 serde_yaml::from_str("- name: push\n match: { tool: bash }\n stub: x\n").unwrap();
1037 let case: Vec<MockDecl> =
1038 serde_yaml::from_str("- name: push\n match: { tool: bash }\n").unwrap();
1039 let err = MockSet::build(&shared, &case, false).unwrap_err();
1040 assert!(err.to_string().contains("duplicate mock name"), "{err}");
1041 }
1042
1043 fn call(tool: &str, command: &str, action: &str, rule: Option<usize>) -> MockCall {
1044 MockCall {
1045 tool: Some(tool.into()),
1046 input: Some(json!({ "command": command })),
1047 action: action.into(),
1048 rule,
1049 mock: None,
1050 }
1051 }
1052
1053 #[test]
1054 fn resolve_fills_mock_names_and_records_for_binds_by_name() {
1055 let set = set(DECLS);
1056 let records = set.resolve(vec![
1057 call("Bash", "git push origin", "stub", Some(0)),
1058 call("Bash", "git status", "allow", None),
1059 call("Bash", "rm -rf /", "deny", Some(1)),
1060 call("Read", "n/a", "allow", None),
1061 ]);
1062 assert_eq!(records[0].mock.as_deref(), Some("push"));
1063 assert!(records[1].mock.is_none());
1064 assert_eq!(records[2].mock.as_deref(), Some("danger"));
1065
1066 let push = set.records_for("push", &records).unwrap();
1068 assert_eq!(push.len(), 1);
1069 assert!(push[0].mocked());
1070 let git = set.records_for("git", &records).unwrap();
1073 assert_eq!(git.len(), 2);
1074 let err = set.records_for("psuh", &records).unwrap_err();
1076 assert!(err.to_string().contains("unknown mock `psuh`"), "{err}");
1077 assert!(err.to_string().contains("push, danger, git"), "{err}");
1078 }
1079
1080 #[test]
1081 fn decide_first_match_wins_and_mirrors_matcher_semantics() {
1082 let set = set(DECLS);
1083 let rules = set.rules().unwrap();
1084 for command in ["git push origin", "git push --force origin"] {
1086 let (rule, action) =
1087 decide(rules, Some("Bash"), Some(&json!({ "command": command }))).unwrap();
1088 assert_eq!(rule, 0, "{command}");
1089 assert!(
1090 matches!(action, AppliedAction::Stub { output, exit_code: 0 }
1091 if output == "Everything up-to-date")
1092 );
1093 }
1094 let (rule, action) = decide(
1096 rules,
1097 Some("Bash"),
1098 Some(&json!({ "command": "rm -rf /tmp" })),
1099 )
1100 .unwrap();
1101 assert_eq!(rule, 1);
1102 assert_eq!(action.kind(), "deny");
1103 assert!(decide(
1105 rules,
1106 Some("Bash"),
1107 Some(&json!({ "command": "git pushy" }))
1108 )
1109 .is_none());
1110 assert!(decide(rules, Some("Read"), Some(&json!({ "command": "git push" }))).is_none());
1111 assert!(decide(rules, None, Some(&json!({ "command": "git push" }))).is_none());
1113 }
1114
1115 #[test]
1116 fn decide_applies_input_predicates_and_coerces_non_strings() {
1117 let set = set(r#"
1118- match:
1119 input:
1120 file_path: { contains: secrets }
1121 depth: { equals: "3" }
1122 deny: no secrets
1123"#);
1124 let rules = set.rules().unwrap();
1125 let input = json!({ "file_path": "/etc/secrets.json", "depth": 3 });
1127 assert!(decide(rules, Some("read"), Some(&input)).is_some());
1128 let input = json!({ "file_path": "/etc/secrets.json" });
1130 assert!(decide(rules, Some("read"), Some(&input)).is_none());
1131 }
1132
1133 #[test]
1134 fn stub_command_quotes_posix_safely() {
1135 assert_eq!(stub_command("clean", 0), "printf '%s\\n' 'clean'");
1136 assert_eq!(
1137 stub_command("it's done", 2),
1138 "printf '%s\\n' 'it'\\''s done'; exit 2"
1139 );
1140 }
1141
1142 #[test]
1143 fn where_matches_field_predicates() {
1144 let clause: BTreeMap<String, FieldPredicate> =
1145 serde_yaml::from_str("command: { contains: \"--force\" }\n").unwrap();
1146 assert!(where_matches(
1147 &clause,
1148 Some(&json!({ "command": "git push --force" }))
1149 ));
1150 assert!(!where_matches(
1151 &clause,
1152 Some(&json!({ "command": "git push" }))
1153 ));
1154 assert!(!where_matches(&clause, None));
1155 let exact: BTreeMap<String, FieldPredicate> =
1157 serde_yaml::from_str("command: git status\n").unwrap();
1158 assert!(where_matches(
1159 &exact,
1160 Some(&json!({ "command": "git status" }))
1161 ));
1162 assert!(!where_matches(
1163 &exact,
1164 Some(&json!({ "command": "git status -s" }))
1165 ));
1166 assert!(where_matches(&BTreeMap::new(), None));
1168 }
1169
1170 #[test]
1171 fn parse_spy_log_reads_oneharness_lines_and_rejects_garbage() {
1172 let log = concat!(
1173 r#"{"harness":"claude-code","event":{"tool_name":"Bash","tool_input":{"command":"git push"}},"action":"stub","rule":0}"#,
1174 "\n\n",
1175 r#"{"harness":"claude-code","event":{"toolName":"shell","toolArgs":{"command":"ls"}},"action":"allow","rule":null}"#,
1176 "\n",
1177 );
1178 let records = parse_spy_log(log).unwrap();
1179 assert_eq!(records.len(), 2);
1180 assert_eq!(records[0].tool.as_deref(), Some("Bash"));
1181 assert_eq!(records[0].input.as_ref().unwrap()["command"], "git push");
1182 assert_eq!(records[0].action, "stub");
1183 assert_eq!(records[0].rule, Some(0));
1184 assert!(records[0].mocked());
1185 assert_eq!(records[1].tool.as_deref(), Some("shell"));
1187 assert_eq!(records[1].rule, None);
1188 assert!(!records[1].mocked());
1189 assert!(parse_spy_log("{\"event\":").is_err());
1191 }
1192
1193 #[test]
1194 fn field_predicate_spec_forms_all_hold_locally() {
1195 let clause: BTreeMap<String, FieldPredicate> = serde_yaml::from_str(
1197 "command: { equals: \"git push\", contains: push, pattern: \"^git\" }\n",
1198 )
1199 .unwrap();
1200 assert!(where_matches(
1201 &clause,
1202 Some(&json!({ "command": "git push" }))
1203 ));
1204 assert!(!where_matches(
1206 &clause,
1207 Some(&json!({ "command": "git pushx" }))
1208 ));
1209 let pat_only: BTreeMap<String, FieldPredicate> =
1210 serde_yaml::from_str("command: { pattern: \"^git\" }\n").unwrap();
1211 assert!(!where_matches(
1212 &pat_only,
1213 Some(&json!({ "command": "use git" }))
1214 ));
1215 }
1216
1217 #[test]
1218 fn matches_call_contains_matches_over_event_haystack() {
1219 let matcher: MockMatch = serde_yaml::from_str("contains: \"git push\"\n").unwrap();
1220 assert!(matcher.matches_call(Some("Bash"), Some(&json!({ "command": "git push" }))));
1221 assert!(!matcher.matches_call(Some("Bash"), Some(&json!({ "command": "ls" }))));
1222 }
1223
1224 #[test]
1225 fn unnamed_decls_get_positional_labels_and_empty_set_reports_none() {
1226 let decls: Vec<MockDecl> =
1228 serde_yaml::from_str("- match: { tool: bash }\n stub: x\n- match: { tool: read }\n")
1229 .unwrap();
1230 let set = MockSet::build(&[], &decls, false).unwrap();
1231 let records = set.resolve(vec![call("bash", "ls", "stub", Some(0))]);
1232 assert_eq!(records[0].mock.as_deref(), Some("mock_0"));
1233 assert_eq!(set.records_for("spy_1", &records).unwrap().len(), 0);
1234 assert_eq!(set.decls().len(), 2);
1235 let empty = MockSet::build(&[], &[], true).unwrap();
1237 let err = empty.records_for("ghost", &[]).unwrap_err();
1238 assert!(err.to_string().contains("declared: none"), "{err}");
1239 }
1240
1241 #[test]
1242 fn decide_applies_rewrite_actions_and_reports_kind() {
1243 let set = set(
1244 "- match: { tool: read, input: { file_path: { pattern: \"secrets\" } } }\n rewrite: { file_path: /tmp/fixture }\n",
1245 );
1246 let rules = set.rules().unwrap();
1247 let input = json!({ "file_path": "/etc/secrets.json" });
1248 let (rule, action) = decide(rules, Some("read"), Some(&input)).unwrap();
1249 assert_eq!(rule, 0);
1250 assert_eq!(action.kind(), "rewrite");
1251 assert!(matches!(action, AppliedAction::Rewrite { input }
1252 if input["file_path"] == "/tmp/fixture"));
1253 assert!(decide(
1256 rules,
1257 Some("read"),
1258 Some(&json!({ "file_path": "/etc/ok" }))
1259 )
1260 .is_none());
1261 }
1262
1263 #[test]
1264 fn describe_records_caps_and_shows_verdicts() {
1265 let mut records: Vec<MockCall> = (0..7)
1266 .map(|i| call("bash", &format!("cmd{i}"), "allow", None))
1267 .collect();
1268 records[0] = call("bash", "git push", "deny", Some(0));
1269 records[1] = MockCall {
1271 tool: Some("read".into()),
1272 input: Some(json!({ "file_path": "/x" })),
1273 action: "allow".into(),
1274 rule: None,
1275 mock: None,
1276 };
1277 let text = describe_records(&records);
1278 assert!(text.contains("bash(git push) [deny]"), "{text}");
1279 assert!(text.contains("read({\"file_path\":\"/x\"})"), "{text}");
1280 assert!(text.contains("… 2 more"), "{text}");
1281 let bare = MockCall {
1283 tool: None,
1284 input: None,
1285 action: "allow".into(),
1286 rule: None,
1287 mock: None,
1288 };
1289 assert_eq!(describe_records(&[bare]), "?()");
1290 }
1291
1292 #[test]
1293 fn matches_call_composes_all_criteria() {
1294 let matcher: MockMatch = serde_yaml::from_str(
1295 "tool: bash\npattern: \"git (status|diff)\"\ninput: { command: { contains: git } }\n",
1296 )
1297 .unwrap();
1298 assert!(matcher.matches_call(Some("Bash"), Some(&json!({ "command": "git status" }))));
1299 assert!(!matcher.matches_call(Some("Bash"), Some(&json!({ "command": "git push" }))));
1300 assert!(!matcher.matches_call(Some("read"), Some(&json!({ "command": "git status" }))));
1301 assert!(!matcher.matches_call(None, Some(&json!({ "command": "git status" }))));
1302 }
1303}