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}