Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edf401f773 |
+2
-42
@@ -3,49 +3,9 @@
|
||||
One line per change, grouped by the release that shipped it. Newest
|
||||
first. Each release names the portal tag it is built against.
|
||||
|
||||
## 0.5.2 (2026-09-28) - portal v0.5.2
|
||||
## 0.3.40 (2026-09-23) - portal v0.3.40
|
||||
|
||||
- Matches portal v0.5.2 (released by portal's publish job).
|
||||
|
||||
## 0.5.1 (2026-09-28) - portal v0.5.1
|
||||
|
||||
- Matches portal v0.5.1 (released by portal's publish job).
|
||||
|
||||
## 0.5.0 (2026-09-28) - portal v0.5.0
|
||||
|
||||
- Matches portal v0.5.0 (released by portal's publish job).
|
||||
|
||||
## 0.4.0 (2026-09-24) - portal v0.4.0
|
||||
|
||||
- Matches portal v0.4.0 (released by portal's publish job).
|
||||
|
||||
## 0.3.47 (2026-09-24) - portal v0.3.47
|
||||
|
||||
- Matches portal v0.3.47 (released by portal's publish job).
|
||||
|
||||
## 0.3.46 (2026-09-23) - portal v0.3.46
|
||||
|
||||
- Matches portal v0.3.46 (released by portal's publish job).
|
||||
|
||||
## 0.3.45 (2026-09-23) - portal v0.3.45
|
||||
|
||||
- Matches portal v0.3.45 (released by portal's publish job).
|
||||
|
||||
## 0.3.44 (2026-09-23) - portal v0.3.44
|
||||
|
||||
- Matches portal v0.3.44 (released by portal's publish job).
|
||||
|
||||
## 0.3.43 (2026-09-23) - portal v0.3.43
|
||||
|
||||
- Matches portal v0.3.43 (released by portal's publish job).
|
||||
|
||||
## 0.3.42 (2026-09-23) - portal v0.3.42
|
||||
|
||||
- Matches portal v0.3.42 (released by portal's publish job).
|
||||
|
||||
## 0.3.41 (2026-09-23) - portal v0.3.41
|
||||
|
||||
- Matches portal v0.3.41 (released by portal's publish job).
|
||||
- Matches portal v0.3.40 (released by portal's publish job).
|
||||
|
||||
## 0.3.39 (2026-09-23) - portal v0.3.39
|
||||
|
||||
|
||||
Generated
+4
-224
@@ -798,7 +798,7 @@ dependencies = [
|
||||
"linked-hash-map",
|
||||
"linked_hash_set",
|
||||
"miette",
|
||||
"nonempty 0.12.0",
|
||||
"nonempty",
|
||||
"ref-cast",
|
||||
"regex",
|
||||
"rustc-literal-escaper",
|
||||
@@ -1060,12 +1060,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
@@ -1167,20 +1161,6 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
@@ -1543,16 +1523,6 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "forwarded-header-value"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
|
||||
dependencies = [
|
||||
"nonempty 0.7.0",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -1630,12 +1600,6 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
@@ -1684,11 +1648,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1739,29 +1701,6 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dashmap",
|
||||
"futures-sink",
|
||||
"futures-timer",
|
||||
"futures-util",
|
||||
"getrandom 0.3.4",
|
||||
"hashbrown 0.16.1",
|
||||
"nonzero_ext",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"quanta",
|
||||
"rand 0.9.5",
|
||||
"smallvec",
|
||||
"spinning_top",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
@@ -1823,23 +1762,6 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
@@ -2071,19 +1993,6 @@ dependencies = [
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-timeout"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
||||
dependencies = [
|
||||
"hyper 1.11.1",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -2287,7 +2196,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "iris"
|
||||
version = "0.5.2"
|
||||
version = "0.3.40"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -3016,12 +2925,6 @@ dependencies = [
|
||||
"signatory",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonempty"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
|
||||
|
||||
[[package]]
|
||||
name = "nonempty"
|
||||
version = "0.12.0"
|
||||
@@ -3031,12 +2934,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -3406,13 +3303,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.5.2"
|
||||
source = "git+https://project.uhhm.no/uhhm/portal.git?tag=v0.5.2#45240b7cabcc59f1802ad1bd432f512a47110d2c"
|
||||
version = "0.3.40"
|
||||
source = "git+https://project.uhhm.no/uhhm/portal.git?tag=v0.3.40#44d5ef368371b7d0d004d9bb0c89a9db267fcfe6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
"async-nats",
|
||||
"async-trait",
|
||||
"aws-sdk-s3",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -3437,7 +3333,6 @@ dependencies = [
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-sessions",
|
||||
"tower_governor",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
@@ -3587,21 +3482,6 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"raw-cpuid",
|
||||
"wasi",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
@@ -3786,15 +3666,6 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reactive_graph"
|
||||
version = "0.2.14"
|
||||
@@ -4695,15 +4566,6 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
|
||||
|
||||
[[package]]
|
||||
name = "spinning_top"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
@@ -5003,17 +4865,6 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
@@ -5092,35 +4943,6 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.14.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"h2 0.4.19",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"hyper 1.11.1",
|
||||
"hyper-timeout",
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"socket2 0.6.5",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -5129,12 +4951,9 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"indexmap 2.14.2",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -5246,23 +5065,6 @@ dependencies = [
|
||||
"tower-sessions-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower_governor"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"forwarded-header-value",
|
||||
"governor",
|
||||
"http 1.5.0",
|
||||
"pin-project",
|
||||
"thiserror 2.0.20",
|
||||
"tonic",
|
||||
"tower",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
@@ -5670,22 +5472,6 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -5695,12 +5481,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "iris"
|
||||
version = "0.5.2"
|
||||
version = "0.3.40"
|
||||
edition = "2021"
|
||||
description = "The iris over a portal site: nothing from a content repo comes through until it checks out"
|
||||
license = "MIT"
|
||||
@@ -10,7 +10,7 @@ repository = "https://project.uhhm.no/uhhm/iris"
|
||||
# The site's own content types and loaders, so iris reads a repo exactly
|
||||
# the way the running site does. Pinned to the portal release it
|
||||
# matches; bumping this tag is what a new iris release is.
|
||||
portal = { git = "https://project.uhhm.no/uhhm/portal.git", tag = "v0.5.2", features = ["ssr"] }
|
||||
portal = { git = "https://project.uhhm.no/uhhm/portal.git", tag = "v0.3.40", features = ["ssr"] }
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
+1
-36
@@ -23,7 +23,6 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
let mut min_accuracy: f64 = 0.0;
|
||||
let mut skim = false;
|
||||
let mut sim_out: Option<String> = None;
|
||||
let mut show_access = false;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
@@ -34,7 +33,6 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
"--needs-tasks" => tasks_out = args.next(),
|
||||
"--skim" => skim = true,
|
||||
"--needs-sim" => sim_out = args.next(),
|
||||
"--access" => show_access = true,
|
||||
"--needs-score" => answers_in = args.next(),
|
||||
"--min-accuracy" => {
|
||||
min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy)
|
||||
@@ -64,7 +62,7 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
(questions, aggregates_map, site, needs_raw)
|
||||
} else {
|
||||
eprintln!(
|
||||
"usage: iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
|
||||
"usage: iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
|
||||
);
|
||||
std::process::exit(2);
|
||||
};
|
||||
@@ -93,39 +91,6 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
questions.len(),
|
||||
aggregates_map.len()
|
||||
);
|
||||
// The access policy the site will compile from this content.
|
||||
// It must type-check against portal's schema: a rule the
|
||||
// schema cannot express would grant nothing at runtime.
|
||||
let policy = portal::access::Policy::from_content(&questions, &aggregates_map);
|
||||
if let Err(e) = policy.validate() {
|
||||
eprintln!("FAIL: access policy does not validate: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("OK: access policy, {} rule(s), validates", policy.rules.len());
|
||||
// What the site mails, checked before anyone receives it:
|
||||
// placeholders that would go out blank, links to pages
|
||||
// that are not there, grants and invites nobody hears of.
|
||||
let findings = crate::mail::check(&questions, &aggregates_map, &site);
|
||||
for f in &findings {
|
||||
let tag = if f.level == needs::Level::Fail { "FAIL" } else { "WARN" };
|
||||
eprintln!("{tag}: {}", f.text);
|
||||
}
|
||||
if findings.iter().any(|f| f.level == needs::Level::Fail) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
let mails = crate::mail::count(&aggregates_map, &site);
|
||||
if mails > 0 {
|
||||
println!("OK: {mails} mail(s): every placeholder fills, every link lands");
|
||||
}
|
||||
if show_access {
|
||||
println!("\n{:<16} {:<44} {:<40} {}", "ACTION", "RESOURCE", "WHO", "WHEN");
|
||||
let mut rows = policy.rules.clone();
|
||||
rows.sort_by(|a, b| (&a.resource, &a.action, &a.who).cmp(&(&b.resource, &b.action, &b.who)));
|
||||
for r in rows {
|
||||
println!("{:<16} {:<44} {:<40} {}", r.action, r.resource, r.who, r.when);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
check_needs(
|
||||
needs_raw.as_deref(),
|
||||
&questions,
|
||||
|
||||
-327
@@ -1,327 +0,0 @@
|
||||
//! What the site's mail promises, checked before it is sent to anyone.
|
||||
//!
|
||||
//! A mail is read alone, away from the page that caused it, by a
|
||||
//! person who cannot ask what a blank means. Portal renders a `{key}`
|
||||
//! it has no value for as nothing, silently, and sends a link to a
|
||||
//! page that is not there as readily as one that is. So the things a
|
||||
//! mail can get wrong without any runtime error are checked here:
|
||||
//!
|
||||
//! - **fail** a placeholder no record in the bucket can fill: the mail
|
||||
//! goes out with a hole in it;
|
||||
//! - **fail** a link to a page the site does not have;
|
||||
//! - **fail** a state that grants a group on a site that sends no mail:
|
||||
//! the account is made and its sign-in link reaches nobody;
|
||||
//! - **fail** an invite mail without its `{link}`, or with a
|
||||
//! placeholder the invite cannot fill;
|
||||
//! - **warn** a placeholder only some of the bucket's forms collect, or
|
||||
//! one every form marks optional: it is blank in the mails about the
|
||||
//! others, or about anyone who skipped it;
|
||||
//! - **warn** a dialogue that does not close: the person is mailed on
|
||||
//! the way in and not told how their case ended;
|
||||
//! - **warn** a mail that tells a person their case is where they may
|
||||
//! now act on it, and gives them no link to do so;
|
||||
//! - **warn** a long answer (a `textarea` field) put in a subject line,
|
||||
//! which becomes the whole brief in someone's inbox list.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use portal::aggregates::AggregateSchema;
|
||||
use portal::content::{self, Question, SiteConfig};
|
||||
|
||||
use crate::needs::Level;
|
||||
|
||||
/// What portal fills in a case mail besides the record's own answers
|
||||
/// (`mail.rs`, `vars_for`).
|
||||
const CASE_VARS: &[&str] = &["email", "site", "chain", "bucket", "state"];
|
||||
/// What portal fills in the invite mail (`mail.rs`, `on_invite`).
|
||||
const INVITE_VARS: &[&str] = &["name", "username", "email", "group", "link", "site", "site_name", "expires"];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Finding {
|
||||
pub level: Level,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// `{key}` exactly as portal's renderer reads one: ASCII alphanumerics,
|
||||
/// `_`, `-` and `.`, closed by `}`. A brace that opens no key is text.
|
||||
pub fn placeholders(template: &str) -> Vec<String> {
|
||||
let mut keys = Vec::new();
|
||||
let mut rest = template;
|
||||
while let Some(start) = rest.find('{') {
|
||||
let after = &rest[start + 1..];
|
||||
let len = after
|
||||
.char_indices()
|
||||
.take_while(|(_, c)| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
|
||||
.last()
|
||||
.map(|(i, c)| i + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
if len > 0 && after[len..].starts_with('}') {
|
||||
keys.push(after[..len].to_string());
|
||||
rest = &after[len + 1..];
|
||||
} else {
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// The paths a template links to on its own site: `{site}/x/y?...`.
|
||||
fn site_links(template: &str) -> Vec<String> {
|
||||
template
|
||||
.match_indices("{site}")
|
||||
.map(|(i, _)| {
|
||||
let tail = &template[i + "{site}".len()..];
|
||||
let end = tail.find(|c: char| c.is_whitespace() || matches!(c, '?' | '#' | ')' | '>' | '"' | '\'')).unwrap_or(tail.len());
|
||||
let path = tail[..end].trim_end_matches(['.', ',', ';', ':']);
|
||||
if path.is_empty() { "/".to_string() } else { path.to_string() }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn page_exists(questions: &HashMap<String, Question>, path: &str) -> bool {
|
||||
let path = path.trim_end_matches('/');
|
||||
let path = if path.is_empty() { "/" } else { path };
|
||||
questions.contains_key(path)
|
||||
|| questions.values().any(|q| q.is_dynamic() && content::path_matches(&q.id, path).is_some())
|
||||
}
|
||||
|
||||
/// For each bucket, the field names of every alternative that records
|
||||
/// into it: one set per form.
|
||||
fn forms_by_bucket(questions: &HashMap<String, Question>) -> BTreeMap<String, Vec<(String, BTreeSet<String>)>> {
|
||||
let mut out: BTreeMap<String, Vec<(String, BTreeSet<String>)>> = BTreeMap::new();
|
||||
for q in questions.values() {
|
||||
for alt in &q.alternatives {
|
||||
if let Some(bucket) = &alt.record_as {
|
||||
let fields = alt.features.iter().flat_map(|f| &f.requirements).map(|r| r.name.clone()).collect();
|
||||
out.entry(bucket.clone()).or_default().push((format!("{} / {}", q.id, alt.name), fields));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// States with no move out of them: where a case ends.
|
||||
fn endings(schema: &AggregateSchema) -> BTreeSet<String> {
|
||||
let mut states: BTreeSet<String> = schema.event_for_state.keys().cloned().collect();
|
||||
states.insert(schema.initial.clone());
|
||||
states.into_iter().filter(|s| schema.allowed(s).is_empty()).collect()
|
||||
}
|
||||
|
||||
/// States reachable from `from` along declared moves, `from` included.
|
||||
fn reachable(schema: &AggregateSchema, from: &str) -> BTreeSet<String> {
|
||||
let mut seen = BTreeSet::from([from.to_string()]);
|
||||
let mut todo = vec![from.to_string()];
|
||||
while let Some(s) = todo.pop() {
|
||||
for next in schema.allowed(&s) {
|
||||
if seen.insert(next.clone()) {
|
||||
todo.push(next.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
pub fn check(questions: &HashMap<String, Question>, aggregates: &HashMap<String, AggregateSchema>, site: &SiteConfig) -> Vec<Finding> {
|
||||
let mut findings = Vec::new();
|
||||
let mut fail = |text: String| findings.push(Finding { level: Level::Fail, text });
|
||||
let forms = forms_by_bucket(questions);
|
||||
let mut warns = Vec::new();
|
||||
|
||||
// Where the submitter may act on their own record: bucket -> state
|
||||
// -> the labels of the moves they may make from there.
|
||||
let mut own_moves: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
|
||||
for q in questions.values() {
|
||||
for alt in &q.alternatives {
|
||||
if let Some(st) = &alt.self_transition {
|
||||
if let Some(schema) = aggregates.get(&st.bucket) {
|
||||
for from in st.from_states(&schema.initial) {
|
||||
own_moves.entry(st.bucket.clone()).or_default().entry(from).or_default().push(st.label.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut buckets: Vec<&AggregateSchema> = aggregates.values().collect();
|
||||
buckets.sort_by(|a, b| a.bucket.cmp(&b.bucket));
|
||||
for schema in buckets {
|
||||
let bucket = &schema.bucket;
|
||||
let bucket_forms = forms.get(bucket).cloned().unwrap_or_default();
|
||||
let mut states: Vec<(&String, &portal::aggregates::MailSpec)> = schema.mail_for_state.iter().collect();
|
||||
states.sort_by(|a, b| a.0.cmp(b.0));
|
||||
|
||||
for (state, mail) in &states {
|
||||
let at = format!("mail on {bucket}:{state}");
|
||||
for key in placeholders(&mail.subject).into_iter().chain(placeholders(&mail.body)) {
|
||||
if CASE_VARS.contains(&key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let having: Vec<&String> = bucket_forms.iter().filter(|(_, f)| f.contains(&key)).map(|(n, _)| n).collect();
|
||||
if having.is_empty() {
|
||||
fail(format!("{at}: {{{key}}} is not a field any form recording into {bucket:?} collects, nor one portal fills ({}) - it would be sent blank", CASE_VARS.join(", ")));
|
||||
} else if questions
|
||||
.values()
|
||||
.flat_map(|q| &q.alternatives)
|
||||
.filter(|a| a.record_as.as_deref() == Some(bucket.as_str()))
|
||||
.flat_map(|a| a.features.iter().flat_map(|f| &f.requirements))
|
||||
.filter(|r| r.name == key)
|
||||
.all(|r| r.optional)
|
||||
{
|
||||
warns.push(format!("{at}: {{{key}}} is optional on every form - blank for anyone who skipped it"));
|
||||
} else if having.len() < bucket_forms.len() {
|
||||
let missing: Vec<&String> = bucket_forms.iter().filter(|(_, f)| !f.contains(&key)).map(|(n, _)| n).collect();
|
||||
warns.push(format!("{at}: {{{key}}} is blank for records sent from {}", missing.iter().map(|n| format!("{n:?}")).collect::<Vec<_>>().join(", ")));
|
||||
}
|
||||
}
|
||||
for key in placeholders(&mail.subject) {
|
||||
let long = questions.values().flat_map(|q| &q.alternatives).filter(|a| a.record_as.as_deref() == Some(bucket.as_str())).flat_map(|a| a.features.iter().flat_map(|f| &f.requirements)).any(|r| r.name == key && r.kind == "textarea");
|
||||
if long {
|
||||
warns.push(format!("{at}: the subject carries {{{key}}}, a long answer (textarea) - the whole of it lands in the subject line"));
|
||||
}
|
||||
}
|
||||
for path in site_links(&mail.body).into_iter().chain(site_links(&mail.subject)) {
|
||||
if path.contains('{') {
|
||||
continue; // filled per record; the page is checked where the pattern is declared
|
||||
}
|
||||
if !page_exists(questions, &path) {
|
||||
fail(format!("{at}: links to {path:?}, which is not a page on this site"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !schema.grants_for_state.is_empty() && site.mail.is_none() {
|
||||
let mut granting: Vec<&String> = schema.grants_for_state.keys().collect();
|
||||
granting.sort();
|
||||
fail(format!("{bucket}: state(s) {granting:?} grant a group, and site.yaml has no `mail: {{ from }}` - the account would be made and its sign-in link sent to nobody"));
|
||||
}
|
||||
|
||||
// The dialogue closes: a person mailed on the way in hears how
|
||||
// it ended, from every ending their case can still reach.
|
||||
let to_submitter: BTreeSet<&String> = states.iter().filter(|(_, m)| m.to_submitter()).map(|(s, _)| *s).collect();
|
||||
if let Some(first) = to_submitter.iter().min_by_key(|s| (s.as_str() != schema.initial, s.to_string())) {
|
||||
let reach = reachable(schema, first);
|
||||
let silent: Vec<String> = endings(schema).into_iter().filter(|e| reach.contains(e) && !to_submitter.contains(e)).collect();
|
||||
if !silent.is_empty() {
|
||||
warns.push(format!("{bucket}: the person is mailed at {first:?} and not told when their case ends at {silent:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
// A mail telling a person their case is where they may act on
|
||||
// it carries the link that lets them.
|
||||
for (state, mail) in &states {
|
||||
if !mail.to_submitter() {
|
||||
continue;
|
||||
}
|
||||
if let Some(labels) = own_moves.get(bucket).and_then(|m| m.get(*state)) {
|
||||
if !mail.body.contains("{chain}") {
|
||||
warns.push(format!("mail on {bucket}:{state}: the person may now {:?} themselves, and the mail gives them no link (`{{chain}}`) to do it", labels));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(invite) = site.mail.as_ref().and_then(|m| m.invite.as_ref()) {
|
||||
let keys: Vec<String> = placeholders(&invite.subject).into_iter().chain(placeholders(&invite.body)).collect();
|
||||
for key in &keys {
|
||||
if !INVITE_VARS.contains(&key.as_str()) {
|
||||
fail(format!("site.yaml mail.invite: {{{key}}} is not something the invite mail knows ({}) - it would be sent blank", INVITE_VARS.join(", ")));
|
||||
}
|
||||
}
|
||||
if !invite.body.contains("{link}") {
|
||||
fail("site.yaml mail.invite: the body has no {link} - the person invited could never sign in".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
findings.extend(warns.into_iter().map(|text| Finding { level: Level::Warn, text }));
|
||||
findings
|
||||
}
|
||||
|
||||
/// How many mails the site declares, invite included.
|
||||
pub fn count(aggregates: &HashMap<String, AggregateSchema>, site: &SiteConfig) -> usize {
|
||||
aggregates.values().map(|s| s.mail_for_state.len()).sum::<usize>()
|
||||
+ usize::from(site.mail.as_ref().is_some_and(|m| m.invite.is_some()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn placeholders_are_read_the_way_portal_renders_them() {
|
||||
assert_eq!(placeholders("Hi {name}, see {site}/decide/x?chain={chain}. {not a key} {}"), ["name", "site", "chain"]);
|
||||
assert_eq!(site_links("Open {site}/decide/trial?chain={chain}. Or {site}."), ["/decide/trial", "/"]);
|
||||
}
|
||||
|
||||
fn site(yaml: &str) -> SiteConfig {
|
||||
serde_yaml::from_str(yaml).unwrap()
|
||||
}
|
||||
|
||||
fn repo(aggregates: &str, pages: &[(&str, &str)]) -> (HashMap<String, Question>, HashMap<String, AggregateSchema>) {
|
||||
let aggregates = portal::aggregates::parse_aggregates_yaml(aggregates).unwrap();
|
||||
let questions = pages
|
||||
.iter()
|
||||
.map(|(id, yaml)| {
|
||||
let mut q: Question = serde_yaml::from_str(yaml).unwrap();
|
||||
q.id = id.to_string();
|
||||
(id.to_string(), q)
|
||||
})
|
||||
.collect();
|
||||
(questions, aggregates)
|
||||
}
|
||||
|
||||
const AGG: &str = "aggregates:\n - bucket: trials\n initial: open\n states:\n open: { event: received, mail: { to: submitter, subject: \"Got it, {name}\", body: \"Withdraw at {site}/decide/trial?chain={chain}\" } }\n approved: { event: approved, mail: { to: submitter, subject: Yes, body: \"{business} is approved\" } }\n declined: { event: declined }\n withdrawn: { event: withdrawn }\n transitions:\n open: [approved, declined, withdrawn]\n";
|
||||
const FORM: &str = "name: Q?\nalternatives:\n - name: Try it\n record_as: trials\n features:\n - name: f\n requirements:\n - { name: name }\n - { name: email, type: email }\n";
|
||||
const DECIDE: &str = "name: Decide?\nalternatives:\n - name: Withdraw\n self_transition: { bucket: trials, to: withdrawn, label: Withdraw }\n";
|
||||
|
||||
#[test]
|
||||
fn a_blank_placeholder_a_dead_link_and_a_silent_ending_are_found() {
|
||||
let (q, a) = repo(AGG, &[("/", FORM), ("/decide/trial", DECIDE)]);
|
||||
let f = check(&q, &a, &site("mail: { from: x@y.no }\n"));
|
||||
let text = |lvl: Level| f.iter().filter(|x| x.level == lvl).map(|x| x.text.clone()).collect::<Vec<_>>();
|
||||
let fails = text(Level::Fail);
|
||||
assert_eq!(fails.len(), 1, "{fails:?}");
|
||||
assert!(fails[0].contains("{business}"));
|
||||
let warns = text(Level::Warn);
|
||||
assert!(warns.iter().any(|w| w.contains("declined") && w.contains("withdrawn")), "{warns:?}");
|
||||
|
||||
let (q, a) = repo(AGG, &[("/", FORM)]);
|
||||
let f = check(&q, &a, &site("mail: { from: x@y.no }\n"));
|
||||
assert!(f.iter().any(|x| x.level == Level::Fail && x.text.contains("/decide/trial")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grant_needs_a_sender_and_an_invite_needs_its_link() {
|
||||
let agg = "aggregates:\n - bucket: b\n initial: open\n states:\n open: { event: received }\n in: { event: in, grants: members }\n transitions:\n open: [in]\n";
|
||||
let (q, a) = repo(agg, &[("/", "name: Q?\nalternatives:\n - name: A\n record_as: b\n features:\n - name: f\n requirements:\n - { name: name }\n - { name: email, type: email }\n")]);
|
||||
assert!(check(&q, &a, &site("title: T\n")).iter().any(|x| x.level == Level::Fail && x.text.contains("sent to nobody")));
|
||||
let f = check(&q, &a, &site("mail: { from: x@y.no, invite: { subject: \"Hi {name}\", body: \"Welcome to {site_name}, {nickname}\" } }\n"));
|
||||
assert!(f.iter().any(|x| x.text.contains("{nickname}")));
|
||||
assert!(f.iter().any(|x| x.text.contains("no {link}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_optional_answer_in_a_mail_is_found() {
|
||||
let agg = AGG.replace("Got it, {name}", "Got it, {nick}");
|
||||
let form = FORM.replace(" - { name: email, type: email }\n", " - { name: email, type: email }\n - { name: nick, optional: true }\n");
|
||||
let (q, a) = repo(&agg, &[("/", &form), ("/decide/trial", DECIDE)]);
|
||||
let f = check(&q, &a, &site("mail: { from: x@y.no }\n"));
|
||||
assert!(f.iter().any(|x| x.level == Level::Warn && x.text.contains("{nick}") && x.text.contains("optional")), "{f:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_answer_in_a_subject_line_is_found() {
|
||||
let agg = AGG.replace("Got it, {name}", "About {brief}");
|
||||
let form = FORM.replace(" - { name: email, type: email }\n", " - { name: email, type: email }\n - { name: brief, type: textarea }\n");
|
||||
let (q, a) = repo(&agg, &[("/", &form), ("/decide/trial", DECIDE)]);
|
||||
let f = check(&q, &a, &site("mail: { from: x@y.no }\n"));
|
||||
assert!(f.iter().any(|x| x.level == Level::Warn && x.text.contains("{brief}") && x.text.contains("subject")), "{f:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mail_about_a_case_the_person_may_act_on_links_to_it() {
|
||||
let agg = AGG.replace("Withdraw at {site}/decide/trial?chain={chain}", "We have it");
|
||||
let (q, a) = repo(&agg, &[("/", FORM), ("/decide/trial", DECIDE)]);
|
||||
let f = check(&q, &a, &site("mail: { from: x@y.no }\n"));
|
||||
assert!(f.iter().any(|x| x.level == Level::Warn && x.text.contains("no link")), "{f:?}");
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -18,7 +18,6 @@
|
||||
//! it matches, so the lint and the site never disagree about what a
|
||||
//! page is.
|
||||
|
||||
mod mail;
|
||||
mod check;
|
||||
mod local;
|
||||
mod needs;
|
||||
@@ -45,7 +44,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
Some(flag) if flag.starts_with("--") => check::run(argv).await,
|
||||
_ => {
|
||||
eprintln!(
|
||||
"iris {v} - matches portal v{v}\n\nusage:\n iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]\n iris replay --path <dir> --nats <url> (--cases <cases.jsonl> | --report-only)",
|
||||
"iris {v} - matches portal v{v}\n\nusage:\n iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]\n iris replay --path <dir> --nats <url> (--cases <cases.jsonl> | --report-only)",
|
||||
v = env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
std::process::exit(2)
|
||||
|
||||
+3
-50
@@ -73,11 +73,6 @@ pub struct Need {
|
||||
/// as a warning, so the gap stays visible without failing CI.
|
||||
#[serde(default)]
|
||||
pub planned: bool,
|
||||
/// Each state of the bucket in plain words, in the site's
|
||||
/// language, for the scorer: state ids are ASCII, and a Norwegian
|
||||
/// `ikke_lost` reads as English to a model.
|
||||
#[serde(default)]
|
||||
pub stage_words: std::collections::BTreeMap<String, String>,
|
||||
/// The finished states that are a good outcome for the business -
|
||||
/// a subset of `done_when`. "lost" is finished; "won" is success.
|
||||
/// What a simulation or a live bucket report counts toward.
|
||||
@@ -576,8 +571,7 @@ pub fn sim_model(
|
||||
json!({
|
||||
"page": step.page,
|
||||
"question": page.name,
|
||||
"context": page_context(page),
|
||||
"options": page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).map(|a| json!({
|
||||
"options": page.alternatives.iter().filter(|a| !a.disabled).map(|a| json!({
|
||||
"name": a.name,
|
||||
"description": plain(&a.description),
|
||||
})).collect::<Vec<_>>(),
|
||||
@@ -603,7 +597,6 @@ pub fn sim_model(
|
||||
json!({
|
||||
"initial": schema.initial,
|
||||
"transitions": schema.transitions,
|
||||
"words": need.stage_words,
|
||||
"desks": desks_over(questions, &need.lands_in).iter().map(|d| json!({
|
||||
"page": d.page,
|
||||
"group": d.group,
|
||||
@@ -653,41 +646,6 @@ fn plain(text: &str) -> String {
|
||||
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// A card a visitor can act on: it leads to a page, records an
|
||||
/// answer, or moves a record. A card with none of those is read, not
|
||||
/// picked - the opener that says what the business does, the look
|
||||
/// around - and is context on the page, never an option.
|
||||
pub fn leads_somewhere(a: &Alternative) -> bool {
|
||||
a.action.is_some() || a.record_as.is_some() || a.self_transition.is_some()
|
||||
}
|
||||
|
||||
/// What the page says before its options: every card that leads
|
||||
/// nowhere, its heading, description and features, as one line each.
|
||||
pub fn page_context(page: &Question) -> String {
|
||||
page.alternatives
|
||||
.iter()
|
||||
.filter(|a| !a.disabled && !leads_somewhere(a))
|
||||
.map(|a| {
|
||||
let mut line = a.name.clone();
|
||||
let description = plain(&a.description);
|
||||
if !description.is_empty() {
|
||||
line.push_str(&format!(": {description}"));
|
||||
}
|
||||
for f in &a.features {
|
||||
let text = plain(&f.description);
|
||||
match (f.name.trim().is_empty(), text.is_empty()) {
|
||||
(true, true) => {}
|
||||
(true, false) => line.push_str(&format!(" {text}")),
|
||||
(false, true) => line.push_str(&format!(" {}.", f.name.trim())),
|
||||
(false, false) => line.push_str(&format!(" {}: {text}", f.name.trim())),
|
||||
}
|
||||
}
|
||||
line
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Every (persona, page-on-their-path) pair with a real choice on it.
|
||||
/// `skim` shows the engine only each alternative's heading - what a
|
||||
/// visitor who never reads the description has to go on.
|
||||
@@ -702,11 +660,10 @@ pub fn tasks(file: &NeedsFile, questions: &Questions, skim: bool) -> Vec<Task> {
|
||||
};
|
||||
for (i, step) in path.iter().enumerate() {
|
||||
let page = &questions[&step.page];
|
||||
let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).collect();
|
||||
let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled).collect();
|
||||
if open.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let context = page_context(page);
|
||||
let mut trail: Vec<String> = path[..=i].iter().map(|s| s.page.clone()).collect();
|
||||
let expect: Vec<String> = viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail)
|
||||
.into_iter()
|
||||
@@ -727,11 +684,7 @@ pub fn tasks(file: &NeedsFile, questions: &Questions, skim: bool) -> Vec<Task> {
|
||||
persona: persona.id.clone(),
|
||||
need: need.id.clone(),
|
||||
page: step.page.clone(),
|
||||
state: if context.is_empty() {
|
||||
BTreeMap::from([("body", plain(&persona.text))])
|
||||
} else {
|
||||
BTreeMap::from([("body", plain(&persona.text)), ("page", context)])
|
||||
},
|
||||
state: BTreeMap::from([("body", plain(&persona.text))]),
|
||||
questions: BTreeMap::from([(
|
||||
"pick",
|
||||
ChoiceQuestion {
|
||||
|
||||
Reference in New Issue
Block a user