plan: a spectator role goes back to the model once; bare-word movers and unlabelled groups parse
Re-planning the contractor showed three wasted rounds: `moves_by: {
checking: submitter }` written as a bare word, a group without a
label, and the two featured owner roles left with nothing to play,
which the builder only warned about after the fact. The bare word and
the missing label now parse (a label defaults to the id), and a plan
that parses with spectator roles is sent back once with the finding;
on the contractor, round three fixed both.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
6e49694211
commit
4d22f4cb44
+27
-6
@@ -112,10 +112,30 @@ fn english() -> String {
|
||||
"en".to_string()
|
||||
}
|
||||
|
||||
/// `moves_by: { checking: submitter }` is what a model writes for a
|
||||
/// single mover, and means the same as `[submitter]`; a bare word or
|
||||
/// a list are both read.
|
||||
fn one_or_many_by_key<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BTreeMap<String, Vec<String>>, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum OneOrMany {
|
||||
One(String),
|
||||
Many(Vec<String>),
|
||||
}
|
||||
let raw: BTreeMap<String, OneOrMany> = BTreeMap::deserialize(d)?;
|
||||
Ok(raw
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, match v { OneOrMany::One(s) => vec![s], OneOrMany::Many(l) => l }))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GroupDef {
|
||||
pub id: String,
|
||||
/// The name the public knows the group by; empty means the id
|
||||
/// made readable.
|
||||
#[serde(default)]
|
||||
pub label: String,
|
||||
/// Groups that may invite people into this one, besides itself and
|
||||
/// the plan's `invites_by`.
|
||||
@@ -183,6 +203,7 @@ pub struct PlanNeed {
|
||||
/// how roles overlap on one aggregate: a project manager prices a
|
||||
/// change, the client approves it, a site manager completes it.
|
||||
#[serde(default)]
|
||||
#[serde(deserialize_with = "one_or_many_by_key")]
|
||||
pub moves_by: BTreeMap<String, Vec<String>>,
|
||||
/// Groups that see these cases on their desk and move nothing.
|
||||
#[serde(default)]
|
||||
@@ -306,8 +327,8 @@ impl Plan {
|
||||
}
|
||||
let mut labelled = BTreeSet::new();
|
||||
for g in &self.groups {
|
||||
if !is_id(&g.id) || !labelled.insert(&g.id) || g.label.trim().is_empty() {
|
||||
bail!("group {:?}: id must be unique ASCII lowercase with - or _, and it needs a label", g.id);
|
||||
if !is_id(&g.id) || !labelled.insert(&g.id) {
|
||||
bail!("group {:?}: id must be unique ASCII lowercase with - or _", g.id);
|
||||
}
|
||||
}
|
||||
for g in self.invites_by.iter().chain(self.groups.iter().flat_map(|g| g.invited_by.iter())) {
|
||||
@@ -938,7 +959,7 @@ fn inviters(plan: &Plan, group: &str) -> BTreeSet<String> {
|
||||
}
|
||||
|
||||
fn label_of(plan: &Plan, group: &str) -> String {
|
||||
plan.groups.iter().find(|g| g.id == group).map(|g| g.label.clone()).unwrap_or_else(|| humanize(group))
|
||||
plan.groups.iter().find(|g| g.id == group).map(|g| g.label.trim().to_string()).filter(|l| !l.is_empty()).unwrap_or_else(|| humanize(group))
|
||||
}
|
||||
|
||||
/// Buckets the public may read, with the needs that fill them.
|
||||
@@ -951,10 +972,10 @@ fn public_view_path(bucket: &str) -> String {
|
||||
}
|
||||
|
||||
/// The options of a select: its own list, or the plan's labelled groups.
|
||||
fn select_options<'a>(plan: &'a Plan, field: &'a Field) -> Vec<&'a str> {
|
||||
fn select_options(plan: &Plan, field: &Field) -> Vec<String> {
|
||||
match field.options_from.as_deref() {
|
||||
Some("groups") => plan.groups.iter().map(|g| g.label.as_str()).collect(),
|
||||
_ => field.options.iter().map(String::as_str).collect(),
|
||||
Some("groups") => plan.groups.iter().map(|g| label_of(plan, &g.id)).collect(),
|
||||
_ => field.options.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user