Skip to main content

cove_rules/
lib.rs

1//! A Rust application that embeds the `examples/rules` package as a typed,
2//! bounded, inspectable decision engine.
3//!
4//! The Cove half of this example is a pull-request review policy: six rules,
5//! a `dyn Rule` catalog, and one `decide` that turns a `PullRequest` into a
6//! `Decision`. `examples/rules/README.md` describes it. This half is what an
7//! embedder writes, and it exists because a rule engine has a shape nothing
8//! else in this repository has: it is compiled once and invoked many times,
9//! against inputs that arrive one at a time from the application around it.
10//!
11//! Everything here uses the public embedding API and nothing else:
12//! [`cove_sema::Compiler`] with the embedder's own [`ModuleSchema`],
13//! [`cove_ir::lower_entry`], [`cove_runtime::Vm`] or
14//! [`cove_runtime::interp::Interpreter`], [`cove_runtime::HostRegistry`],
15//! [`cove_runtime::Grants`], [`cove_runtime::Budget`], and
16//! [`cove_runtime::TraceSink`]. Nothing reaches into an internal module, and
17//! nothing duplicates a checker or runtime table.
18//!
19//! # What the embedding is shaped by
20//!
21//! Two facts about the API decide the shape of everything below, and both are
22//! worth stating plainly because they are not obvious from the outside.
23//!
24//! **An exported function is called with values, and an entry is called with
25//! process arguments.** `Vm::invoke` and `Interpreter::invoke` take a
26//! `Vec<Value>`, so [`Session::evaluate`] hands `rules.embedded.evaluate` a
27//! `rules.policy.PullRequest` the Rust side built and reads a
28//! `rules.policy.Decision` out of what came back. Nothing crosses the Host
29//! API boundary on that path at all. `run_entry` is still there and still
30//! takes a `Vec<Rc<str>>`, because a *command* has strings to hand over —
31//! [`Session::decide`] uses it, and what it costs against `evaluate` is the
32//! measurement in `examples/rules/README.md`.
33//!
34//! This is the half of the example that changed. It was written when
35//! `run_entry` was the only way in: the request identifier went in as the one
36//! process argument and the pull request came back out through a Host API
37//! call into this crate's own module, because there was no other channel.
38//! Issue #150 was that gap. The `reviews` module below is not a casualty of
39//! closing it — it is what the two paths are measured against each other
40//! with, and it is still what a host module *is* for, which is reaching
41//! something outside the process rather than carrying an argument into it.
42//!
43//! **A host module the toolchain does not ship is invisible to `cove
44//! check`.** `reviews` is this crate's, so `cove check` in `examples/`
45//! reports one warning about `examples/rules/embedded/embedded.cove`, whose
46//! help text says to hand the schema to the compiler. That is what
47//! [`RulePackage::load`] does, and [`REVIEWS`] is the single value both the
48//! checker and the boundary read, so the two cannot drift. That no `cove`
49//! command can be handed one is issue #151.
50//!
51//! # The two things that are paid once
52//!
53//! Parsing and resolving and checking the package, and lowering one entry to
54//! `cove-ir`'s executable IR. The two are [`RulePackage`] and [`Lowering`],
55//! in that order, and `cove-rules-measure` reports what each of them costs
56//! against what one invocation costs. There used to be a third: the
57//! predecessor backend read a lowered program's struct shapes, enum shapes
58//! and constants at construction time and built a table of each, so
59//! [`RulePackage::serve`] had a cost of its own worth reporting beside the
60//! other two. `cove-ir` computes every layout once, while lowering, and
61//! [`cove_runtime::Vm::new`] reads none of that back out of the program — it
62//! allocates the heap region and a table sized to the program's string count,
63//! neither of which grows with how much the program declares. What
64//! `RulePackage::serve` costs is still reported, because a reader should not
65//! have to take "now cheap" on faith, but it is no longer a pass over the
66//! program the way the other two are.
67
68use std::collections::BTreeMap;
69use std::path::{Path, PathBuf};
70use std::rc::Rc;
71use std::sync::{Arc, Mutex};
72use std::time::{Duration, Instant};
73
74use cove_diag::{render, SourceMap};
75use cove_runtime::interp::Interpreter;
76use cove_runtime::value::MapKey;
77use cove_runtime::{
78    Budget, Effect, FieldSchema, Grants, HostApi, HostRegistry, HostType, Limits, ModuleSchema,
79    OperationSchema, RecordedValue, Runtime, RuntimeError, TraceEvent, TraceSink, TypeSchema,
80    Value, Vm,
81};
82use cove_sema::package::{Module, Package, Unit};
83use cove_sema::resolve::Program;
84use cove_sema::{Compiler, Config, HostSchemas};
85
86// ---------------------------------------------------------------------------
87// The module this host registers
88
89/// What `reviews` declares about itself.
90///
91/// One value, read twice: [`Reviews::module_schema`] answers with it, so the
92/// boundary holds every call to it, and [`RulePackage::load`] hands the same
93/// value to [`Compiler::with_host_schema`], so the checker holds every call
94/// site to it. Nothing about the module is written down a second time, which
95/// is the whole reason the two ends cannot disagree.
96///
97/// `PullRequest` carries ten fields and each is declared as the shape it is,
98/// `labels: Set<String>` included. It was an `Array<String>` until issue #153,
99/// not because a pull request's labels are a sequence but because [`HostType`]
100/// had no `Set` to say otherwise, and the Cove side carried a loop that turned
101/// one into the other wherever a rule asked a membership question. A schema
102/// that cannot say what a field is makes the program say it instead.
103///
104/// Nothing here is checked by writing it down twice: the boundary holds a
105/// value to this table, and
106/// `the_schema_declares_only_types_a_value_could_have` holds the table itself
107/// to `ModuleSchema::validate`, which is the one thing a `HostType` can now
108/// say and no value satisfy.
109pub const REVIEWS: ModuleSchema = ModuleSchema {
110    name: "reviews",
111    capability: "reviews",
112    operations: &[
113        OperationSchema {
114            name: "pull",
115            params: &[HostType::String],
116            variadic: false,
117            result: HostType::Result(&HostType::Named("reviews.PullRequest"), &HostType::Error),
118            capability: "reviews",
119            effect: Effect::Read,
120            cancellable: false,
121            recordable: true,
122            result_is_task_safe: true,
123        },
124        OperationSchema {
125            name: "record",
126            params: &[
127                HostType::String,
128                HostType::String,
129                HostType::Int,
130                HostType::String,
131            ],
132            variadic: false,
133            result: HostType::Result(&HostType::Unit, &HostType::Error),
134            capability: "reviews",
135            effect: Effect::ReversibleWrite,
136            cancellable: false,
137            recordable: true,
138            result_is_task_safe: true,
139        },
140    ],
141    types: &[TypeSchema {
142        name: "PullRequest",
143        cases: &[],
144        fields: &[
145            FieldSchema {
146                name: "id",
147                ty: HostType::String,
148            },
149            FieldSchema {
150                name: "title",
151                ty: HostType::String,
152            },
153            FieldSchema {
154                name: "author",
155                ty: HostType::String,
156            },
157            FieldSchema {
158                name: "targetBranch",
159                ty: HostType::String,
160            },
161            FieldSchema {
162                name: "changedLines",
163                ty: HostType::Int,
164            },
165            FieldSchema {
166                name: "filesTouched",
167                ty: HostType::Array(&HostType::String),
168            },
169            FieldSchema {
170                name: "labels",
171                ty: HostType::Set(&HostType::String),
172            },
173            FieldSchema {
174                name: "approvals",
175                ty: HostType::Int,
176            },
177            FieldSchema {
178                name: "isDraft",
179                ty: HostType::Bool,
180            },
181            FieldSchema {
182                name: "hasTests",
183                ty: HostType::Bool,
184            },
185        ],
186    }],
187    resources: &[],
188};
189
190/// The next version of [`REVIEWS`], with one operation and one field added
191/// and nothing taken away.
192///
193/// An additive change: `blame` is an operation no rule package calls yet, and
194/// `openedAt` is a field none reads. The compatibility test holds this to the
195/// rule an additive change has to obey, which is that a package written
196/// against the older schema goes on checking and running unchanged.
197pub const REVIEWS_NEXT: ModuleSchema = ModuleSchema {
198    name: "reviews",
199    capability: "reviews",
200    operations: &[
201        REVIEWS.operations[0],
202        REVIEWS.operations[1],
203        OperationSchema {
204            name: "blame",
205            params: &[HostType::String, HostType::String],
206            variadic: false,
207            result: HostType::Result(&HostType::String, &HostType::Error),
208            capability: "reviews",
209            effect: Effect::Read,
210            cancellable: false,
211            recordable: true,
212            result_is_task_safe: true,
213        },
214    ],
215    types: &[TypeSchema {
216        name: "PullRequest",
217        cases: &[],
218        fields: PULL_REQUEST_WITH_OPENED_AT,
219    }],
220    resources: &[],
221};
222
223/// [`REVIEWS_NEXT`]'s field list: every field [`REVIEWS`] declares, and one
224/// more.
225const PULL_REQUEST_WITH_OPENED_AT: &[FieldSchema] = &[
226    REVIEWS.types[0].fields[0],
227    REVIEWS.types[0].fields[1],
228    REVIEWS.types[0].fields[2],
229    REVIEWS.types[0].fields[3],
230    REVIEWS.types[0].fields[4],
231    REVIEWS.types[0].fields[5],
232    REVIEWS.types[0].fields[6],
233    REVIEWS.types[0].fields[7],
234    REVIEWS.types[0].fields[8],
235    REVIEWS.types[0].fields[9],
236    FieldSchema {
237        name: "openedAt",
238        ty: HostType::Int,
239    },
240];
241
242/// [`REVIEWS`] with `changedLines` renamed, which is the breaking change.
243///
244/// A field a caller reads is part of the interface whether or not anybody
245/// wrote that down, so renaming one is a break. The compatibility test holds
246/// this to the rule a breaking change has to obey, which is that the checker
247/// says so, at the line that reads the field, before anything runs.
248pub const REVIEWS_RENAMED: ModuleSchema = ModuleSchema {
249    name: "reviews",
250    capability: "reviews",
251    operations: REVIEWS.operations,
252    types: &[TypeSchema {
253        name: "PullRequest",
254        cases: &[],
255        fields: PULL_REQUEST_RENAMED,
256    }],
257    resources: &[],
258};
259
260/// [`REVIEWS_RENAMED`]'s field list, in which `changedLines` is gone.
261const PULL_REQUEST_RENAMED: &[FieldSchema] = &[
262    REVIEWS.types[0].fields[0],
263    REVIEWS.types[0].fields[1],
264    REVIEWS.types[0].fields[2],
265    REVIEWS.types[0].fields[3],
266    FieldSchema {
267        name: "changedLineCount",
268        ty: HostType::Int,
269    },
270    REVIEWS.types[0].fields[5],
271    REVIEWS.types[0].fields[6],
272    REVIEWS.types[0].fields[7],
273    REVIEWS.types[0].fields[8],
274    REVIEWS.types[0].fields[9],
275];
276
277// ---------------------------------------------------------------------------
278// The values that cross
279
280/// A pull request, as the application around this embedding holds one.
281///
282/// A plain Rust struct with no Cove in it. [`PullRequest::to_cove`] is the
283/// one place it becomes a value the boundary can carry, and that conversion
284/// is what `cove-rules-measure` counts the allocations of.
285#[derive(Clone, Debug, PartialEq, Eq)]
286pub struct PullRequest {
287    pub id: String,
288    pub title: String,
289    pub author: String,
290    pub target_branch: String,
291    pub changed_lines: i64,
292    pub files_touched: Vec<String>,
293    pub labels: Vec<String>,
294    pub approvals: i64,
295    pub is_draft: bool,
296    pub has_tests: bool,
297}
298
299impl PullRequest {
300    /// This pull request as the struct value `reviews.PullRequest` names,
301    /// which is what the Host API boundary carries.
302    pub fn to_cove(&self) -> Value {
303        Value::structure("reviews.PullRequest", self.fields())
304    }
305
306    /// The same ten fields as the struct value `rules.policy.PullRequest`
307    /// names, which is what [`Session::evaluate`] hands the rules directly.
308    ///
309    /// The two are different types and always were: one is a
310    /// `cove_schema::TypeSchema` this crate wrote in Rust and the other is a
311    /// struct the rule package declared, and a Host API schema has no way to
312    /// name the second. What is new is that a host may build the second —
313    /// which is what makes `rules.embedded.pullRequest`'s field-by-field
314    /// rebuild unnecessary on this path, and is where a chunk of what the
315    /// boundary cost went.
316    pub fn to_policy(&self) -> Value {
317        Value::structure("rules.policy.PullRequest", self.fields())
318    }
319
320    /// The ten fields, in the order both declarations list them.
321    ///
322    /// Every one of them is an allocation: an `Rc<str>` for each name and
323    /// each string, a shared slice for each of the two arrays, a vector for
324    /// the field list, and one more for the struct. The measurement counts
325    /// them rather than estimating them, because the point of counting is to
326    /// find out whether the estimate was right.
327    ///
328    /// The *order* matters to exactly one of the two consumers. The boundary
329    /// reads a `reviews.PullRequest`'s fields by name and does not care; the
330    /// lowering reads a `rules.policy.PullRequest`'s by index, so a value
331    /// whose fields are not the declaration's in the declaration's order is
332    /// refused by `Vm::invoke` before anything runs. That the two
333    /// declarations list the same ten in the same order is a convenience
334    /// rather than a rule, and it is what lets one list serve both.
335    fn fields(&self) -> Vec<(&'static str, Value)> {
336        vec![
337            ("id", Value::string(self.id.as_str())),
338            ("title", Value::string(self.title.as_str())),
339            ("author", Value::string(self.author.as_str())),
340            ("targetBranch", Value::string(self.target_branch.as_str())),
341            ("changedLines", Value::int(self.changed_lines)),
342            ("filesTouched", strings(&self.files_touched)),
343            ("labels", label_set(&self.labels)),
344            ("approvals", Value::int(self.approvals)),
345            ("isDraft", Value::bool(self.is_draft)),
346            ("hasTests", Value::bool(self.has_tests)),
347        ]
348    }
349}
350
351/// `texts` as the Cove `Array<String>` both declarations admit.
352fn strings(texts: &[String]) -> Value {
353    Value::array(texts.iter().map(|t| Value::string(t.as_str())))
354}
355
356/// `labels` as the Cove `Set<String>` both declarations admit.
357///
358/// A `Set`'s elements are `MapKey`s rather than `Value`s, which is Cove's
359/// own restriction on what may be a key showing through: a host writes the key
360/// it means. Nothing is walked or de-duplicated here that the set does not do
361/// itself, which is the whole difference from what this used to be -- an
362/// `Array<String>` the Cove side turned into a `Set` on every membership
363/// question, because a Host API schema had no `Set` to declare one with.
364fn label_set(labels: &[String]) -> Value {
365    Value::set(labels.iter().map(|label| MapKey::Str(label.clone())))
366}
367
368/// What a review policy demands, as the application receives it.
369///
370/// The mirror of `rules.policy.ReviewPolicy`, and the type an embedder's
371/// caller actually acts on: nothing downstream of [`Decision::from_cove`]
372/// holds a [`Value`].
373#[derive(Clone, Debug, PartialEq, Eq)]
374pub enum ReviewPolicy {
375    /// Nothing beyond what the repository asks of every change.
376    Normal,
377    /// Reviewers, and the reason they are being asked for.
378    Require { reviewers: i64, reason: String },
379    /// The change may not land, and why.
380    Block { reason: String },
381}
382
383/// One thing a rule noticed, as the application receives it.
384#[derive(Clone, Debug, PartialEq, Eq)]
385pub struct Finding {
386    pub rule: String,
387    pub severity: String,
388    pub reason: String,
389    pub reviewers: i64,
390}
391
392/// A policy and the findings that argued for it.
393#[derive(Clone, Debug, PartialEq, Eq)]
394pub struct Decision {
395    pub policy: ReviewPolicy,
396    pub findings: Vec<Finding>,
397}
398
399impl Decision {
400    /// Reads a decision out of what an invocation answered.
401    ///
402    /// The entry is declared `Result<Decision, Error>`, so what arrives is a
403    /// `Result` enum whose `Ok` payload is a `rules.policy.Decision` struct.
404    /// Every step names what it expected, because a decoder that answers
405    /// `None` tells its caller that something was wrong and not what.
406    ///
407    /// This walk is the outbound half of what the measurement attributes to
408    /// conversion. It is deliberately written the obvious way — ask for the
409    /// case, read the field by name, clone the string — rather than the fast
410    /// way, since what an embedder writes is what an embedder's cost is.
411    ///
412    /// It asks through the readers on [`Value`] and matches on none of its
413    /// variants, which is what issue #186 added them for: the shapes below
414    /// are the ones a `rules.policy` declaration states, and nothing here
415    /// says how the runtime holds one.
416    pub fn from_cove(value: &Value) -> Result<Decision, String> {
417        Decision::of(ok_payload(value)?)
418    }
419
420    /// Reads a decision out of a bare `rules.policy.Decision`.
421    ///
422    /// `rules.embedded.evaluate` is declared `-> Decision` rather than `->
423    /// Result<Decision, Error>`, because nothing it does can fail: it takes
424    /// the pull request as an argument instead of fetching it, and fetching
425    /// it was the only fallible step. So the answer arrives unwrapped, and
426    /// [`Decision::from_cove`] is this with the `Result` peeled off first.
427    pub fn of(decision: &Value) -> Result<Decision, String> {
428        let Some(type_name) = decision.declared_type() else {
429            return Err(format!("expected a `Decision` struct, found {decision}"));
430        };
431        if type_name != "rules.policy.Decision" {
432            return Err(format!(
433                "expected `rules.policy.Decision`, found `{type_name}`"
434            ));
435        }
436        Ok(Decision {
437            policy: policy_of(field(decision, "policy")?)?,
438            findings: findings_of(field(decision, "findings")?)?,
439        })
440    }
441}
442
443/// What an `Ok` carries, or a message saying what arrived instead.
444///
445/// `Result` is a builtin, so the readers for it are the four that predate
446/// issue #186: a host asks "is this an `Ok`?" and gets the payload as the
447/// answer, without stating the case names itself.
448fn ok_payload(value: &Value) -> Result<&Value, String> {
449    if let Some([payload]) = value.ok_payload() {
450        return Ok(payload);
451    }
452    if let Some([error]) = value.err_payload() {
453        return Err(format!("the rules answered `Err`: {error}"));
454    }
455    Err(format!("expected a `Result`, found {value}"))
456}
457
458/// One field of a struct value, or a message naming the field that was
459/// missing and the type that should have carried it.
460///
461/// One message for both halves of what [`Value::field`] answers `None` to,
462/// because the type name is what distinguishes them: a value that is not a
463/// struct at all reports `Int` where a struct missing the field reports
464/// `rules.policy.Decision`.
465fn field<'v>(value: &'v Value, name: &str) -> Result<&'v Value, String> {
466    value
467        .field(name)
468        .ok_or_else(|| format!("`{}` carries no field `{name}`", value.type_name()))
469}
470
471/// A `rules.policy.ReviewPolicy` value as the Rust enum.
472fn policy_of(value: &Value) -> Result<ReviewPolicy, String> {
473    let (Some(case), Some(payload)) = (value.case(), value.payload()) else {
474        return Err(format!("expected a `ReviewPolicy`, found {value}"));
475    };
476    match case {
477        "Normal" => Ok(ReviewPolicy::Normal),
478        "Require" => {
479            let requirement = &payload[0];
480            Ok(ReviewPolicy::Require {
481                reviewers: int(field(requirement, "reviewers")?)?,
482                reason: text(field(requirement, "reason")?)?,
483            })
484        }
485        "Block" => Ok(ReviewPolicy::Block {
486            reason: text(&payload[0])?,
487        }),
488        case => Err(format!("`ReviewPolicy` has no case `{case}`")),
489    }
490}
491
492/// An `Array<Finding>` as the Rust vector.
493fn findings_of(value: &Value) -> Result<Vec<Finding>, String> {
494    let Some(items) = value.items() else {
495        return Err(format!("expected an `Array<Finding>`, found {value}"));
496    };
497    items
498        .iter()
499        .map(|finding| {
500            // A struct is the one shape with fields, so `fields()` answering
501            // is the question "is this a `Finding`?" asked without naming a
502            // variant.
503            if finding.fields().is_none() {
504                return Err(format!("expected a `Finding`, found {finding}"));
505            }
506            let severity = field(finding, "severity")?;
507            let Some(case) = severity.case() else {
508                return Err("a `Finding` carries a `Severity`".to_string());
509            };
510            Ok(Finding {
511                rule: text(field(finding, "rule")?)?,
512                severity: case.to_string(),
513                reason: text(field(finding, "reason")?)?,
514                reviewers: int(field(finding, "reviewers")?)?,
515            })
516        })
517        .collect()
518}
519
520/// A `String` value as a Rust `String`.
521fn text(value: &Value) -> Result<String, String> {
522    value
523        .as_str()
524        .map(str::to_string)
525        .ok_or_else(|| format!("expected a `String`, found {value}"))
526}
527
528/// An `Int` value as an `i64`.
529fn int(value: &Value) -> Result<i64, String> {
530    value
531        .as_int()
532        .ok_or_else(|| format!("expected an `Int`, found {value}"))
533}
534
535// ---------------------------------------------------------------------------
536// The host
537
538/// What the host wrote down about one decision, under the request identifier
539/// that produced it.
540#[derive(Clone, Debug, PartialEq, Eq)]
541pub struct Recorded {
542    pub request: String,
543    pub policy: String,
544    pub reviewers: i64,
545    pub trail: String,
546}
547
548/// A way for a test to make the host misbehave, so that the boundary can be
549/// seen holding it to its own schema.
550#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
551pub enum Fault {
552    /// The host answers what it declared.
553    #[default]
554    None,
555    /// `pull` answers an `Int`, which its schema does not admit.
556    WrongResultType,
557    /// `pull` fails with a runtime error rather than with a Cove `Err`.
558    Broken,
559}
560
561/// The embedder's own host module: the pull requests the application holds,
562/// and the decisions it has been told about.
563pub struct Reviews {
564    /// The pull requests, by the request identifier that names each.
565    ///
566    /// Behind a `Mutex` because [`HostApi::call`] takes `&self` and a real
567    /// application's queue is written to while the engine is reading it.
568    open: Mutex<BTreeMap<String, PullRequest>>,
569    /// Every decision the rules reported back, in order.
570    recorded: Arc<Mutex<Vec<Recorded>>>,
571    /// The schema this module answers with, which is [`REVIEWS`] unless a
572    /// compatibility test asked for another.
573    schema: ModuleSchema,
574    /// How this host is asked to misbehave, if at all.
575    fault: Fault,
576}
577
578impl Reviews {
579    /// A host serving `open`, keyed by request identifier.
580    pub fn new(open: BTreeMap<String, PullRequest>) -> Reviews {
581        Reviews {
582            open: Mutex::new(open),
583            recorded: Arc::new(Mutex::new(Vec::new())),
584            schema: REVIEWS,
585            fault: Fault::None,
586        }
587    }
588
589    /// The same host, declaring `schema` instead of [`REVIEWS`].
590    pub fn with_schema(mut self, schema: ModuleSchema) -> Reviews {
591        self.schema = schema;
592        self
593    }
594
595    /// The same host, made to misbehave in the one named way.
596    pub fn with_fault(mut self, fault: Fault) -> Reviews {
597        self.fault = fault;
598        self
599    }
600
601    /// The log every decision is recorded in, shared with the host so a
602    /// caller can read it after the run.
603    pub fn log(&self) -> Arc<Mutex<Vec<Recorded>>> {
604        Arc::clone(&self.recorded)
605    }
606
607    /// `pr` as the `reviews.PullRequest` *this host's own* schema declares,
608    /// which is [`PullRequest::to_cove`]'s ten fields plus whatever a newer
609    /// schema added and `PullRequest` has no place to hold.
610    ///
611    /// `Vm` materialises a Host API result into the full physical layout its
612    /// schema declares the moment the call returns, rather than reading a
613    /// field lazily the way the tree-walking interpreter's tagged value does
614    /// — `docs/LINEAR_VM.md` is why every value has one fixed shape. So a
615    /// host that declares [`REVIEWS_NEXT`] and answers only the ten fields
616    /// [`REVIEWS`] always had is answering a value its own schema does not
617    /// admit, whether or not the rule package reads the eleventh: the words
618    /// for `openedAt` have to come from somewhere before the struct is a
619    /// struct at all. This crate has no opening time to report, so it
620    /// answers zero, which is a fixed answer good enough for a decision
621    /// nothing in `examples/rules/embedded/embedded.cove` reads.
622    fn answer(&self, pr: &PullRequest) -> Value {
623        let mut fields = pr.fields();
624        let declares_opened_at = self
625            .schema
626            .declared_type("PullRequest")
627            .is_some_and(|declared| declared.fields.iter().any(|field| field.name == "openedAt"));
628        if declares_opened_at {
629            fields.push(("openedAt", Value::int(0)));
630        }
631        Value::structure("reviews.PullRequest", fields)
632    }
633}
634
635impl HostApi for Reviews {
636    fn module_schema(&self) -> ModuleSchema {
637        self.schema
638    }
639
640    fn call(&self, op: &str, args: Vec<Value>) -> Result<Value, RuntimeError> {
641        // The boundary checked the operation, the arity, and every argument
642        // type against the schema above before dispatching, so nothing here
643        // restates any of it.
644        match op {
645            "pull" => {
646                let [request] = args.as_slice() else {
647                    unreachable!("checked by HostRegistry::call")
648                };
649                let Some(request) = request.as_str() else {
650                    unreachable!("checked by HostRegistry::call")
651                };
652                match self.fault {
653                    Fault::WrongResultType => return Ok(Value::int(7)),
654                    Fault::Broken => {
655                        return Err(RuntimeError::new(
656                            "the review queue is unreachable".to_string(),
657                        ))
658                    }
659                    Fault::None => {}
660                }
661                Ok(match self.open.lock().unwrap().get(request) {
662                    Some(pr) => Value::ok(self.answer(pr)),
663                    None => Value::err(Value::error(format!("no request named `{request}`"))),
664                })
665            }
666            "record" => {
667                let [request, policy, reviewers, trail] = args.as_slice() else {
668                    unreachable!("checked by HostRegistry::call")
669                };
670                let (Some(request), Some(policy), Some(reviewers), Some(trail)) = (
671                    request.as_str(),
672                    policy.as_str(),
673                    reviewers.as_int(),
674                    trail.as_str(),
675                ) else {
676                    unreachable!("checked by HostRegistry::call")
677                };
678                self.recorded.lock().unwrap().push(Recorded {
679                    request: request.to_string(),
680                    policy: policy.to_string(),
681                    reviewers,
682                    trail: trail.to_string(),
683                });
684                Ok(Value::ok(Value::unit()))
685            }
686            "blame" => Ok(Value::ok(Value::string("nobody"))),
687            other => unreachable!("`reviews` declares no operation `{other}`"),
688        }
689    }
690}
691
692// ---------------------------------------------------------------------------
693// Compiling once
694
695/// What loading and checking the rule package cost.
696#[derive(Clone, Copy, Debug, Default)]
697pub struct LoadCost {
698    /// Reading every `.cove` file off disk.
699    pub read: Duration,
700    /// Parsing them.
701    pub parse: Duration,
702    /// Resolving and type-checking, which is where the schema is read.
703    pub check: Duration,
704    /// How many files were loaded.
705    pub files: usize,
706    /// How many modules they made.
707    pub modules: usize,
708}
709
710/// A rule package, parsed and checked once.
711///
712/// This is the artefact an embedder holds for the life of the process. It
713/// carries no host, no budget, and no backend: those belong to a run, and a
714/// package outlives every run made from it.
715pub struct RulePackage {
716    sources: Arc<SourceMap>,
717    program: Arc<Program>,
718    /// The Host API schema this package was checked against, held so
719    /// [`RulePackage::lower`] can hand `cove_ir::lower_entry` the same set
720    /// [`Compiler::with_host_schema`] checked it against. The two must agree:
721    /// a lowering that read a different set could build a `reviews.pull` call
722    /// against a signature the checker never confirmed.
723    schema: ModuleSchema,
724    cost: LoadCost,
725}
726
727impl RulePackage {
728    /// Loads, parses, and checks the rule package rooted at `root`, holding
729    /// every call into `reviews` to `schema`.
730    ///
731    /// `root` is `examples/rules`. Every directory under it holding `.cove`
732    /// files becomes one module, named by its path — `rules`, `rules.policy`,
733    /// `rules.catalog` and so on — which is the rule
734    /// `cove_sema::package::load` follows on disk. It is done here rather
735    /// than by that function for one reason: an embedder composes a package
736    /// out of the rules its user wrote, and it is entitled to decide what is
737    /// in it.
738    ///
739    /// The schema goes to [`Compiler::with_host_schema`], so a call into
740    /// `reviews` is checked at its call site, against the same table the
741    /// boundary will hold it to.
742    pub fn load(root: &Path, schema: ModuleSchema) -> Result<RulePackage, String> {
743        let mut cost = LoadCost::default();
744
745        let started = Instant::now();
746        let mut files: Vec<(String, PathBuf, String)> = Vec::new();
747        collect(root, root, &mut files)?;
748        files.sort();
749        cost.read = started.elapsed();
750        cost.files = files.len();
751
752        let started = Instant::now();
753        let mut sources = SourceMap::new();
754        let mut modules: BTreeMap<String, Module> = BTreeMap::new();
755        for (name, path, text) in files {
756            let file = sources.add(path.clone(), &text);
757            let ast = cove_syntax::parse_file(&sources, file)
758                .map_err(|items| report(&sources, &items))?;
759            modules
760                .entry(name.clone())
761                .or_insert_with(|| Module {
762                    name: name.clone(),
763                    dir: path.parent().unwrap_or(root).to_path_buf(),
764                    units: Vec::new(),
765                })
766                .units
767                .push(Unit { file, path, ast });
768        }
769        cost.parse = started.elapsed();
770        cost.modules = modules.len();
771
772        // This embedder composes its package by hand rather than through
773        // `cove_sema::package::load`, for the reason given above — and that
774        // means it, and not `load`, is responsible for attaching the
775        // standard library `cove_schema::builtins::STANDARD_LIBRARY`'s
776        // methods resolve into.
777        cove_sema::stdlib::install(&mut sources, &mut modules)
778            .map_err(|items| report(&sources, &items))?;
779
780        let package = Package {
781            root: root.to_path_buf(),
782            config: Config::default(),
783            modules,
784        };
785        let started = Instant::now();
786        let program = Compiler::new()
787            .with_host_schema(schema)
788            .compile(&package)
789            .map_err(|items| report(&sources, &items))?;
790        cost.check = started.elapsed();
791
792        Ok(RulePackage {
793            sources: Arc::new(sources),
794            program: Arc::new(program),
795            schema,
796            cost,
797        })
798    }
799
800    /// What loading this package cost.
801    pub fn cost(&self) -> LoadCost {
802        self.cost
803    }
804
805    /// Whatever the checker accepted but doubted, rendered.
806    ///
807    /// Empty for a package checked against a schema that describes every host
808    /// module it names, which is what an embedder should expect to see.
809    pub fn notices(&self) -> Vec<String> {
810        self.program
811            .notices
812            .iter()
813            .map(|item| render(&self.sources, item))
814            .collect()
815    }
816
817    /// Lowers one entry to `cove-ir`'s executable IR.
818    ///
819    /// Per entry rather than per package, because that is what
820    /// [`cove_ir::lower_entry`] does: it lowers what the entry can reach and
821    /// nothing else. An embedder that invokes two entries lowers twice, once
822    /// each, and holds both for the life of the process.
823    ///
824    /// There is no separate validation step to time. The predecessor lowered
825    /// to a form a second pass then checked; `cove_ir::lower_entry` verifies
826    /// as it goes and answers a lowering that is already known good or a
827    /// `Vec<Diagnostic>` naming what was wrong, so [`Lowering`] has one
828    /// duration where it used to have two.
829    ///
830    /// The schema handed to [`cove_ir::lower_entry`] is [`RulePackage::load`]'s
831    /// own — the one [`Compiler::with_host_schema`] checked this package
832    /// against — because a `reviews.pull` call has to lower against the same
833    /// signature the checker confirmed it against, or the two could disagree
834    /// about what the boundary looks like.
835    pub fn lower(&self, module: &str, entry: &str) -> Result<Lowering, String> {
836        let started = Instant::now();
837        let schemas = HostSchemas::new().with(self.schema);
838        let program = cove_ir::lower_entry(&self.program, &self.sources, &schemas, module, entry)
839            .map_err(|items| {
840            format!(
841                "`{module}.{entry}` does not lower: {}",
842                report(&self.sources, &items)
843            )
844        })?;
845        let lower = started.elapsed();
846
847        Ok(Lowering {
848            functions: program.functions.len(),
849            ir: Arc::new(program),
850            lower,
851        })
852    }
853
854    /// Builds one backend over `hosts` and hands it to `body`.
855    ///
856    /// The one `Vm` or interpreter `body` is given serves every invocation
857    /// `body` makes, which is what compile-once/invoke-many means on this
858    /// API. `cove-rules-measure` reports what building it costs separately
859    /// from an invocation's, though for `Vm` that cost is no longer a pass
860    /// over the program: `cove_ir::lower_entry` computed every layout while
861    /// [`RulePackage::lower`] ran, so [`cove_runtime::Vm::new`] allocates the
862    /// heap region and a table sized to the program's string count and reads
863    /// nothing else back out of the lowered program. The predecessor backend
864    /// read the program's struct shapes, enum shapes and constants at this
865    /// point and built a table of each, which is what made building *it* a
866    /// cost worth reporting in the first place.
867    ///
868    /// The borrow is why this takes a closure rather than answering with a
869    /// session. An `Vm` borrows the `Runtime` and the lowered program, both
870    /// of which live for exactly as long as this call, and nothing
871    /// Cove-shaped may leave it in any case: a `Value` is `Rc`-based and is
872    /// not `Send`.
873    pub fn serve<T>(
874        &self,
875        hosts: Arc<HostRegistry>,
876        lowering: Option<&Lowering>,
877        body: impl FnOnce(&mut Session<'_>) -> T,
878    ) -> T {
879        let runtime = Runtime::new(
880            Arc::clone(&self.program),
881            Arc::clone(&self.sources),
882            Arc::clone(&hosts),
883        );
884        let started = Instant::now();
885        let backend = match lowering {
886            Some(lowering) => {
887                Backend::Vm(Box::new(Vm::new(&runtime, runtime.hosts(), &lowering.ir)))
888            }
889            None => Backend::Ast(Box::new(Interpreter::new(&runtime))),
890        };
891        let mut session = Session {
892            build: started.elapsed(),
893            backend,
894        };
895        body(&mut session)
896    }
897}
898
899/// Every `.cove` file under `dir`, with the module name its directory gives
900/// it and the text it holds.
901fn collect(
902    root: &Path,
903    dir: &Path,
904    into: &mut Vec<(String, PathBuf, String)>,
905) -> Result<(), String> {
906    let entries =
907        std::fs::read_dir(dir).map_err(|e| format!("cannot read `{}`: {e}", dir.display()))?;
908    let mut subdirs = Vec::new();
909    for entry in entries {
910        let entry = entry.map_err(|e| format!("cannot read `{}`: {e}", dir.display()))?;
911        let path = entry.path();
912        if path.is_dir() {
913            subdirs.push(path);
914        } else if path.extension().and_then(|e| e.to_str()) == Some("cove") {
915            let text = std::fs::read_to_string(&path)
916                .map_err(|e| format!("cannot read `{}`: {e}", path.display()))?;
917            into.push((module_name(root, dir), path, text));
918        }
919    }
920    subdirs.sort();
921    for subdir in subdirs {
922        collect(root, &subdir, into)?;
923    }
924    Ok(())
925}
926
927/// The module a directory holds: `rules` for the root, and the path below it
928/// joined with dots for anything deeper.
929fn module_name(root: &Path, dir: &Path) -> String {
930    let mut parts = vec!["rules".to_string()];
931    if let Ok(rest) = dir.strip_prefix(root) {
932        parts.extend(
933            rest.components()
934                .map(|c| c.as_os_str().to_string_lossy().into_owned()),
935        );
936    }
937    parts.join(".")
938}
939
940/// Diagnostics, rendered the way `cove check` renders them.
941fn report(sources: &SourceMap, items: &[cove_diag::Diagnostic]) -> String {
942    items.iter().map(|item| render(sources, item)).collect()
943}
944
945/// What lowering one entry cost, and what it produced.
946pub struct Lowering {
947    ir: Arc<cove_ir::Program>,
948    /// How many functions the entry reached.
949    pub functions: usize,
950    /// Lowering itself, verification included.
951    ///
952    /// The predecessor backend timed lowering and validating separately,
953    /// because they were two passes over two different representations.
954    /// `cove_ir::lower_entry` verifies as it lowers rather than after, so
955    /// there is one duration rather than two.
956    pub lower: Duration,
957}
958
959/// Which backend a session runs on.
960///
961/// Both are boxed, which is not a statement about either: an `Interpreter`
962/// and a `Vm` are each several hundred bytes of stacks and tables, so an
963/// enum holding either inline is as wide as the wider one wherever it is
964/// passed. A session is built once per run and invoked many times, so the
965/// indirection costs nothing that anything here measures. Nothing else about
966/// the shape follows from it — the arms below deref through the box and read
967/// the way they read.
968enum Backend<'a> {
969    Ast(Box<Interpreter<'a>>),
970    Vm(Box<Vm<'a>>),
971}
972
973/// One backend, ready to be invoked as many times as an embedder likes.
974pub struct Session<'a> {
975    backend: Backend<'a>,
976    /// What building the backend cost.
977    build: Duration,
978}
979
980impl Session<'_> {
981    /// What building this session's backend cost.
982    pub fn build_time(&self) -> Duration {
983        self.build
984    }
985
986    /// Calls `module.entry` with the values `args`, and answers what it
987    /// produced.
988    ///
989    /// The seam an application uses. `invoke` holds `args` to the signature
990    /// the checker resolved for `module.entry` — the parameter count, and
991    /// each value against its declared type, followed into a declared
992    /// struct's fields — and refuses before the first instruction if they do
993    /// not match. Both backends answer it the same way; nothing here knows
994    /// which one is underneath.
995    pub fn invoke(
996        &mut self,
997        module: &str,
998        entry: &str,
999        args: Vec<Value>,
1000    ) -> Result<Value, RuntimeError> {
1001        match &mut self.backend {
1002            Backend::Ast(interpreter) => interpreter.invoke(module, entry, args),
1003            Backend::Vm(vm) => vm.invoke(module, entry, args),
1004        }
1005    }
1006
1007    /// Runs `module.entry` as a command would, handing it `args` as the
1008    /// process arguments an entry may declare.
1009    ///
1010    /// The other seam, and the only one there used to be. It is what
1011    /// [`Session::decide`] uses to reach `rules.embedded.decideRequest`,
1012    /// which is the control the Host API boundary is measured with.
1013    pub fn run(&mut self, module: &str, entry: &str, args: &[&str]) -> Result<Value, RuntimeError> {
1014        let args: Vec<Rc<str>> = args.iter().map(|arg| (*arg).into()).collect();
1015        match &mut self.backend {
1016            Backend::Ast(interpreter) => interpreter.run_entry(module, entry, args),
1017            Backend::Vm(vm) => vm.run_entry(module, entry, args),
1018        }
1019    }
1020
1021    /// Decides `pr` by invoking `module.entry` with it, and reads the
1022    /// [`Decision`] back.
1023    ///
1024    /// This is what an application calls once per request. Nothing crosses
1025    /// the Host API boundary: the pull request goes in as an argument and the
1026    /// decision comes back as a result, so a run of it makes no host call at
1027    /// all and needs no capability.
1028    pub fn evaluate(
1029        &mut self,
1030        module: &str,
1031        entry: &str,
1032        pr: &PullRequest,
1033    ) -> Result<Decision, String> {
1034        let value = self
1035            .invoke(module, entry, vec![pr.to_policy()])
1036            .map_err(|error| error.message)?;
1037        Decision::of(&value)
1038    }
1039
1040    /// Decides `pr` the same way, bounded by `limits` for this request and no
1041    /// other.
1042    ///
1043    /// This is what an application that runs somebody else's rules calls. A
1044    /// rule package is not the application's code: it can loop, and an
1045    /// application would rather be told which request went wrong than stop
1046    /// serving. `invoke_within` installs a `Budget` built from `limits` as the
1047    /// invocation is entered, so the fuel, the deadline and the host-call
1048    /// limit are this request's -- and the next request gets its own, on the
1049    /// same `Vm`, with none of the 168 allocations that rebuilding a backend
1050    /// per request costs.
1051    ///
1052    /// The deadline runs from here rather than from wherever the `Limits` were
1053    /// written, which is what makes a per-request deadline mean the request.
1054    ///
1055    /// A failure comes back as the message it came back as; nothing about a
1056    /// stopped invocation damages the session, exactly as for a host that
1057    /// failed.
1058    pub fn evaluate_within(
1059        &mut self,
1060        limits: Limits,
1061        module: &str,
1062        entry: &str,
1063        pr: &PullRequest,
1064    ) -> Result<Decision, String> {
1065        let value = match &mut self.backend {
1066            Backend::Ast(interpreter) => {
1067                interpreter.invoke_within(Budget::new(limits), module, entry, vec![pr.to_policy()])
1068            }
1069            Backend::Vm(vm) => {
1070                vm.invoke_within(Budget::new(limits), module, entry, vec![pr.to_policy()])
1071            }
1072        }
1073        .map_err(|error| error.message)?;
1074        Decision::of(&value)
1075    }
1076
1077    /// The same decision the other way: `module.entry` is run with the
1078    /// request identifier as its one process argument, fetches the pull
1079    /// request through `reviews.pull`, and reports the answer through
1080    /// `reviews.record`.
1081    pub fn decide(&mut self, module: &str, entry: &str, request: &str) -> Result<Decision, String> {
1082        let value = self
1083            .run(module, entry, &[request])
1084            .map_err(|error| error.message)?;
1085        Decision::from_cove(&value)
1086    }
1087
1088    /// The boundary route, bounded by `limits` for this request and no other.
1089    ///
1090    /// The counterpart of [`Session::evaluate_within`] for the way in that
1091    /// makes host calls, and the reason it is here as well as that one: ADR
1092    /// 0024 says `max_host_calls` is the control that bounds *effects*
1093    /// exactly, where fuel bounds work only to within a straight line. An
1094    /// application that wants to cap what one request may do to the outside
1095    /// world sets it, and until issue #152 it could only set it for the life
1096    /// of the session.
1097    pub fn decide_within(
1098        &mut self,
1099        limits: Limits,
1100        module: &str,
1101        entry: &str,
1102        request: &str,
1103    ) -> Result<Decision, String> {
1104        let args: Vec<Rc<str>> = vec![request.into()];
1105        let value = match &mut self.backend {
1106            Backend::Ast(interpreter) => {
1107                interpreter.run_entry_within(Budget::new(limits), module, entry, args)
1108            }
1109            Backend::Vm(vm) => vm.run_entry_within(Budget::new(limits), module, entry, args),
1110        }
1111        .map_err(|error| error.message)?;
1112        Decision::from_cove(&value)
1113    }
1114
1115    /// How many instructions every invocation on this session has executed
1116    /// between them, or `None` on the interpreter, which counts none.
1117    pub fn instructions(&self) -> Option<u64> {
1118        match &self.backend {
1119            Backend::Ast(_) => None,
1120            Backend::Vm(vm) => Some(vm.instructions()),
1121        }
1122    }
1123}
1124
1125// ---------------------------------------------------------------------------
1126// Watching the boundary
1127
1128/// A trace sink that keeps the host calls a run made, in order.
1129///
1130/// What it is for is the deliverable that says every invocation and trace is
1131/// linked to an application request identifier. Both `reviews` operations take
1132/// that identifier as their first argument, so every `HostCall` event carries
1133/// it, and [`Calls::for_request`] is the query that shows it.
1134#[derive(Default)]
1135pub struct Calls(Mutex<Vec<RecordedCall>>);
1136
1137/// One recorded host call: the module, the operation, and the first argument
1138/// when it was a string, which for `reviews` is the request identifier.
1139pub type RecordedCall = (String, String, Option<String>);
1140
1141impl Calls {
1142    /// Every call recorded, as `module`, `op`, and the first argument when it
1143    /// was a string.
1144    pub fn all(&self) -> Vec<RecordedCall> {
1145        self.0.lock().unwrap().clone()
1146    }
1147
1148    /// The operations recorded under `request`, in order.
1149    pub fn for_request(&self, request: &str) -> Vec<String> {
1150        self.all()
1151            .into_iter()
1152            .filter(|(_, _, first)| first.as_deref() == Some(request))
1153            .map(|(module, op, _)| format!("{module}.{op}"))
1154            .collect()
1155    }
1156}
1157
1158impl TraceSink for Calls {
1159    fn record(&self, event: TraceEvent) {
1160        if let TraceEvent::HostCall {
1161            module, op, args, ..
1162        } = event
1163        {
1164            let first = match args.first() {
1165                Some(RecordedValue::Carried(cove_runtime::Transfer::Str(text))) => {
1166                    Some(text.clone())
1167                }
1168                _ => None,
1169            };
1170            self.0.lock().unwrap().push((module, op, first));
1171        }
1172    }
1173}
1174
1175// ---------------------------------------------------------------------------
1176// Setting one up
1177
1178/// Everything an embedding holds beside the compiled package: the registry a
1179/// run calls through, the log the host writes decisions to, and the trace it
1180/// records host calls in.
1181pub struct Embedding {
1182    /// The registry, ready to be handed to [`RulePackage::serve`].
1183    pub hosts: Arc<HostRegistry>,
1184    /// What the rules have reported back, in order.
1185    pub log: Arc<Mutex<Vec<Recorded>>>,
1186    /// Every host call the run made, with the request that made it.
1187    pub calls: Arc<Calls>,
1188}
1189
1190/// Registers `reviews`, grants `grants`, imposes `limits`, and watches the
1191/// boundary.
1192///
1193/// Registering a module does not grant it: a capability missing from `grants`
1194/// makes every call into the module a refusal, which is one of the cases the
1195/// tests beside this file pin.
1196///
1197/// `limits` is what bounds the *session*: it is installed on the registry
1198/// before anything runs, and every invocation the session makes spends out of
1199/// the one budget. That is what an application wants for the limits that are
1200/// about the process rather than about a request.
1201///
1202/// It is no longer the only choice, which it was when this example was
1203/// written. A limit that belongs to one request is what a rule engine actually
1204/// wants -- a rule package is somebody else's code, and an application running
1205/// one wants to be told when a rule loops rather than to stop serving -- and
1206/// [`Session::evaluate_within`] is that: the same compiled package, the same
1207/// `Vm`, and a `Budget` per invocation. Issue #152 was the gap, and
1208/// `examples/rules/README.md` says what it used to cost.
1209///
1210/// Pass [`Limits::default`] here for a session that is bounded per request and
1211/// not otherwise, which is what the cases beside this file do.
1212pub fn embedding(reviews: Reviews, grants: &[&str], limits: Limits) -> Embedding {
1213    embedding_traced(reviews, grants, limits, true)
1214}
1215
1216/// The same, with the trace sink left off.
1217///
1218/// A sink that is going to read an event needs the event to be complete, so
1219/// `HostRegistry` describes every argument and every result a call carried
1220/// whenever one is installed -- a deep copy of each, per call. A registry with
1221/// no sink installed keeps the `NullSink` it was built with, which answers
1222/// `is_recording()` with `false`, and the description is skipped.
1223///
1224/// That difference is worth a whole embedding of its own because it is what
1225/// tracing costs, and `cove-rules-measure` prints the two rows beside each
1226/// other. It is not a small number when the value being described is a
1227/// ten-field struct carrying two arrays.
1228pub fn embedding_without_trace(reviews: Reviews, grants: &[&str], limits: Limits) -> Embedding {
1229    embedding_traced(reviews, grants, limits, false)
1230}
1231
1232/// Both of the above.
1233fn embedding_traced(reviews: Reviews, grants: &[&str], limits: Limits, trace: bool) -> Embedding {
1234    let log = reviews.log();
1235    let calls = Arc::new(Calls::default());
1236    let mut hosts = HostRegistry::new(Grants::new(grants.to_vec()));
1237    hosts.register(Box::new(reviews));
1238    hosts.set_budget(Budget::new(limits));
1239    if trace {
1240        hosts.set_trace(Arc::clone(&calls) as Arc<dyn TraceSink>);
1241    }
1242    Embedding {
1243        hosts: Arc::new(hosts),
1244        log,
1245        calls,
1246    }
1247}
1248
1249/// The six pull requests this example is demonstrated on, by request
1250/// identifier.
1251///
1252/// The same six `rules.fixtures` declares in Cove, written again in Rust
1253/// because in a real embedding they arrive from the application. That they
1254/// are written twice is a hazard, so a test asserts the two agree: the
1255/// decision reached over the host's copy of a pull request and the decision
1256/// reached over the package's own have to be the same decision.
1257pub fn samples() -> BTreeMap<String, PullRequest> {
1258    let mut open = BTreeMap::new();
1259    for (request, pr) in [
1260        ("req-1", clean()),
1261        ("req-2", large()),
1262        ("req-3", guarded()),
1263        ("req-4", waived()),
1264        ("req-5", draft()),
1265        ("req-6", labelled()),
1266    ] {
1267        open.insert(request.to_string(), pr);
1268    }
1269    open
1270}
1271
1272/// A small change with tests, on an ordinary branch.
1273fn clean() -> PullRequest {
1274    PullRequest {
1275        id: "pr-1001".to_string(),
1276        title: "Correct a typo in the changelog".to_string(),
1277        author: "ada".to_string(),
1278        target_branch: "main".to_string(),
1279        changed_lines: 4,
1280        files_touched: vec!["CHANGELOG.md".to_string()],
1281        labels: vec!["docs".to_string()],
1282        approvals: 0,
1283        is_draft: false,
1284        has_tests: true,
1285    }
1286}
1287
1288/// A change over the size threshold, with tests.
1289fn large() -> PullRequest {
1290    PullRequest {
1291        id: "pr-1002".to_string(),
1292        title: "Rewrite the scheduler".to_string(),
1293        author: "grace".to_string(),
1294        target_branch: "main".to_string(),
1295        changed_lines: 2400,
1296        files_touched: vec!["src/scheduler.rs".to_string(), "src/queue.rs".to_string()],
1297        labels: Vec::new(),
1298        approvals: 1,
1299        is_draft: false,
1300        has_tests: true,
1301    }
1302}
1303
1304/// A change that reaches a guarded directory without a waiver.
1305fn guarded() -> PullRequest {
1306    PullRequest {
1307        id: "pr-1003".to_string(),
1308        title: "Rotate the signing key".to_string(),
1309        author: "linus".to_string(),
1310        target_branch: "main".to_string(),
1311        changed_lines: 30,
1312        files_touched: vec!["auth/keys.yaml".to_string()],
1313        labels: Vec::new(),
1314        approvals: 0,
1315        is_draft: false,
1316        has_tests: false,
1317    }
1318}
1319
1320/// The same guarded directory, with the waiver label.
1321fn waived() -> PullRequest {
1322    PullRequest {
1323        id: "pr-1004".to_string(),
1324        title: "Rotate the signing key, reviewed".to_string(),
1325        author: "linus".to_string(),
1326        target_branch: "main".to_string(),
1327        changed_lines: 30,
1328        files_touched: vec!["auth/keys.yaml".to_string()],
1329        labels: vec!["security-reviewed".to_string()],
1330        approvals: 2,
1331        is_draft: false,
1332        has_tests: false,
1333    }
1334}
1335
1336/// A draft nobody has asked for review on yet.
1337fn draft() -> PullRequest {
1338    PullRequest {
1339        id: "pr-1005".to_string(),
1340        title: "Sketch a cache".to_string(),
1341        author: "ada".to_string(),
1342        target_branch: "main".to_string(),
1343        changed_lines: 12,
1344        files_touched: vec!["src/cache.rs".to_string()],
1345        labels: Vec::new(),
1346        approvals: 0,
1347        is_draft: true,
1348        has_tests: false,
1349    }
1350}
1351
1352/// A change aimed at the protected branch, carrying the heaviest label.
1353fn labelled() -> PullRequest {
1354    PullRequest {
1355        id: "pr-1006".to_string(),
1356        title: "Drop the v1 wire format".to_string(),
1357        author: "grace".to_string(),
1358        target_branch: "release".to_string(),
1359        changed_lines: 300,
1360        files_touched: vec!["src/wire.rs".to_string(), "docs/wire.md".to_string()],
1361        labels: vec!["breaking-change".to_string(), "migration".to_string()],
1362        approvals: 0,
1363        is_draft: false,
1364        has_tests: true,
1365    }
1366}
1367
1368/// Where the rule package lives, relative to this crate.
1369///
1370/// A path rather than `include_str!`, because the sources an embedder
1371/// compiles are its user's and arrive when the process starts. A binary that
1372/// carried them inside itself would be `cove build`, which is a different
1373/// thing with a different ADR.
1374pub fn package_root() -> PathBuf {
1375    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1376        .parent()
1377        .expect("the host crate sits inside the rule package")
1378        .to_path_buf()
1379}