use std::collections::HashSet;
use std::io::{Read, Write};
use anyhow::{anyhow, bail, Context, Result};
use serde_json::{json, Map, Value};
const REPORT_FIELDS: [&str; 5] = [
"purpose",
"owner",
"latest_outcome",
"next_step",
"acceptance_task_id",
];
fn field_limit(key: &str) -> Option<usize> {
match key {
"purpose" => Some(4000),
"owner" => Some(1000),
"latest_outcome" => Some(4000),
"next_step" => Some(2000),
"acceptance_task_id" => Some(160),
_ => None,
}
}
const MAX_CRITERIA: usize = 50;
const MAX_ENTRIES: usize = 200;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutcomeError {
pub message: String,
}
impl OutcomeError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for OutcomeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Task outcomes: {}", self.message)
}
}
impl std::error::Error for OutcomeError {}
type OutcomeResult<T> = std::result::Result<T, OutcomeError>;
fn as_object(value: &Value) -> Option<&Map<String, Value>> {
value.as_object()
}
fn fields(value: &Value, keys: &[&str]) -> bool {
match as_object(value) {
Some(object) => object.keys().all(|key| keys.contains(&key.as_str())),
None => false,
}
}
fn is_js_space(ch: char) -> bool {
matches!(
ch,
'\u{0009}'..='\u{000d}'
| '\u{0020}'
| '\u{00a0}'
| '\u{1680}'
| '\u{2000}'..='\u{200a}'
| '\u{2028}'
| '\u{2029}'
| '\u{202f}'
| '\u{205f}'
| '\u{3000}'
| '\u{feff}'
)
}
fn text(value: &Value, maximum: usize) -> bool {
match value.as_str() {
Some(text) => {
!text.trim_matches(is_js_space).is_empty() && text.encode_utf16().count() <= maximum
}
None => false,
}
}
fn criterion_id_ok(value: &str) -> bool {
let mut chars = value.chars();
match chars.next() {
Some(first) if first.is_ascii_alphanumeric() => {}
_ => return false,
}
let mut length = 1;
for ch in chars {
length += 1;
if length > 128 || !(ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')) {
return false;
}
}
true
}
fn short_text(value: &Value) -> bool {
text(value, 160)
}
#[derive(Debug, Clone, PartialEq)]
struct Criterion {
task_id: Value,
criterion_id: Value,
description: Value,
}
impl Criterion {
fn to_value(&self) -> Value {
let mut object = Map::new();
object.insert("task_id".into(), self.task_id.clone());
object.insert("criterion_id".into(), self.criterion_id.clone());
object.insert("description".into(), self.description.clone());
Value::Object(object)
}
}
#[derive(Debug, Clone, PartialEq)]
struct Milestone {
id: Value,
title: Value,
criteria: Vec<Criterion>,
}
impl Milestone {
fn to_value(&self) -> Value {
let mut object = Map::new();
object.insert("id".into(), self.id.clone());
object.insert("title".into(), self.title.clone());
object.insert(
"criteria".into(),
Value::Array(self.criteria.iter().map(Criterion::to_value).collect()),
);
Value::Object(object)
}
}
fn criterion_key(criterion: &Criterion) -> String {
serde_json::to_string(&Value::Array(vec![
criterion.task_id.clone(),
criterion.criterion_id.clone(),
]))
.expect("criterion IDs are JSON scalars")
}
fn require(condition: bool, message: &str) -> OutcomeResult<()> {
if condition {
Ok(())
} else {
Err(OutcomeError::new(message))
}
}
fn is_js_falsy(value: &Value) -> bool {
value.is_null()
|| value.as_bool() == Some(false)
|| value.as_str() == Some("")
|| value.as_i64() == Some(0)
|| value.as_u64() == Some(0)
|| value.as_f64().is_some_and(|number| number == 0.0)
}
fn same_json_stringify(left: &Value, right: &Value) -> bool {
match (serde_json::to_string(left), serde_json::to_string(right)) {
(Ok(left), Ok(right)) => left == right,
_ => false,
}
}
fn report(input: &Value) -> OutcomeResult<Value> {
if !fields(input, &REPORT_FIELDS) {
return Err(OutcomeError::new("Invalid outcome report fields"));
}
let object = as_object(input).expect("fields() accepted an object");
for (key, value) in object {
let limit = field_limit(key).expect("fields() rejected unknown keys");
if !(value.is_null() || text(value, limit)) {
return Err(OutcomeError::new(format!("Invalid outcome {key}")));
}
}
Ok(input.clone())
}
fn milestone(input: &Value) -> OutcomeResult<Milestone> {
let object = as_object(input);
require(
fields(input, &["id", "title", "criteria"])
&& object.is_some_and(|object| {
object.get("id").is_some_and(short_text)
&& object.get("title").is_some_and(|t| text(t, 1000))
}),
"Invalid milestone contract",
)?;
let object = object.expect("fields() accepted an object");
let criteria = object
.get("criteria")
.and_then(Value::as_array)
.ok_or_else(|| OutcomeError::new("Invalid milestone criteria"))?;
require(
!criteria.is_empty() && criteria.len() <= MAX_CRITERIA,
"Invalid milestone criteria",
)?;
let mut normalized = Vec::with_capacity(criteria.len());
for criterion in criteria {
let criterion_object = as_object(criterion);
let valid = fields(criterion, &["task_id", "criterion_id", "description"])
&& criterion_object.is_some_and(|object| {
object.get("task_id").is_some_and(short_text)
&& object
.get("criterion_id")
.and_then(Value::as_str)
.is_some_and(criterion_id_ok)
&& object.get("description").is_some_and(|d| text(d, 1000))
});
require(valid, "Invalid milestone criterion")?;
let object = criterion_object.expect("fields() accepted an object");
normalized.push(Criterion {
task_id: object["task_id"].clone(),
criterion_id: object["criterion_id"].clone(),
description: object["description"].clone(),
});
}
let mut seen = HashSet::new();
for criterion in &normalized {
let key = criterion_key(criterion);
require(seen.insert(key), "Duplicate milestone criterion")?;
}
Ok(Milestone {
id: object["id"].clone(),
title: object["title"].clone(),
criteria: normalized,
})
}
fn empty_outcomes() -> Value {
let mut object = Map::new();
object.insert("schema".into(), json!(1));
object.insert("tasks".into(), Value::Array(Vec::new()));
object.insert("milestones".into(), Value::Array(Vec::new()));
Value::Object(object)
}
pub fn apply_task_outcomes(state: &Value, input: &Value) -> OutcomeResult<Option<Value>> {
let input_object =
as_object(input).ok_or_else(|| OutcomeError::new("Invalid task outcomes"))?;
if !input_object.contains_key("outcome") && !input_object.contains_key("milestone_contract") {
return Ok(None);
}
let state_object =
as_object(state).ok_or_else(|| OutcomeError::new("Invalid task outcomes"))?;
let mut next = match state_object.get("task_outcomes") {
Some(value) if !is_js_falsy(value) => value.clone(),
_ => empty_outcomes(),
};
let next_object = next
.as_object_mut()
.ok_or_else(|| OutcomeError::new("Invalid task outcomes"))?;
if let Some(outcome) = input_object.get("outcome") {
let update = report(outcome)?;
let id = input_object.get("id").cloned().unwrap_or(Value::Null);
let tasks = next_object
.get_mut("tasks")
.and_then(Value::as_array_mut)
.ok_or_else(|| OutcomeError::new("Invalid task outcomes"))?;
let index = tasks
.iter()
.position(|item| as_object(item).is_some_and(|item| item.get("task_id") == Some(&id)));
let value = match index {
Some(index) => {
let mut merged = as_object(&tasks[index])
.ok_or_else(|| OutcomeError::new("Invalid task outcomes"))?
.clone();
let patch = as_object(&update).expect("report() returned an object");
for (key, value) in patch {
merged.insert(key.clone(), value.clone());
}
Value::Object(merged)
}
None => {
let mut merged = Map::new();
merged.insert("task_id".into(), id);
let patch = as_object(&update).expect("report() returned an object");
for (key, value) in patch {
merged.insert(key.clone(), value.clone());
}
Value::Object(merged)
}
};
match index {
Some(index) => tasks[index] = value,
None => {
require(tasks.len() < MAX_ENTRIES, "Outcome task limit reached")?;
tasks.push(value);
}
}
}
if let Some(contract) = input_object.get("milestone_contract") {
let value = milestone(contract)?;
require(
input_object.get("milestone") == Some(&value.id),
"Milestone contract must match the task milestone",
)?;
let milestones = next_object
.get_mut("milestones")
.and_then(Value::as_array_mut)
.ok_or_else(|| OutcomeError::new("Invalid task outcomes"))?;
let stored = value.to_value();
let index = milestones
.iter()
.position(|item| as_object(item).is_some_and(|item| item.get("id") == Some(&value.id)));
match index {
Some(index) => {
require(
same_json_stringify(&milestones[index], &stored),
"Milestone criteria are fixed; declare changed criteria with a new milestone ID",
)?;
}
None => {
require(milestones.len() < MAX_ENTRIES, "Milestone limit reached")?;
milestones.push(stored);
}
}
}
Ok(Some(next))
}
pub fn validate_task_outcomes(state: &Value) -> OutcomeResult<()> {
let state_object =
as_object(state).ok_or_else(|| OutcomeError::new("Invalid task outcomes"))?;
let Some(value) = state_object.get("task_outcomes") else {
return Ok(());
};
let valid = fields(value, &["schema", "tasks", "milestones"])
&& as_object(value).is_some_and(|object| {
object.get("schema") == Some(&json!(1))
&& object
.get("tasks")
.and_then(Value::as_array)
.is_some_and(|tasks| tasks.len() <= MAX_ENTRIES)
&& object
.get("milestones")
.and_then(Value::as_array)
.is_some_and(|milestones| milestones.len() <= MAX_ENTRIES)
});
require(valid, "Invalid task outcomes")?;
let object = as_object(value).ok_or_else(|| OutcomeError::new("Invalid task outcomes"))?;
let tasks = object["tasks"]
.as_array()
.ok_or_else(|| OutcomeError::new("Invalid task outcomes"))?;
let declared = state_object
.get("tasks")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
let mut keys = [
"task_id",
"purpose",
"owner",
"latest_outcome",
"next_step",
"acceptance_task_id",
]
.to_vec();
let mut ids: HashSet<String> = HashSet::new();
for task in tasks {
let task_object = as_object(task);
let task_id = task_object.and_then(|object| object.get("task_id"));
let declared_task = task_id.is_some_and(|id| {
declared
.iter()
.any(|item| as_object(item).and_then(|item| item.get("id")) == Some(id))
});
let valid = fields(task, &keys)
&& task_id.is_some_and(short_text)
&& declared_task
&& task_id
.and_then(|id| serde_json::to_string(id).ok())
.is_some_and(|id| !ids.contains(&id));
require(valid, "Invalid outcome task")?;
let task_id = task_id.expect("the shape check accepted task_id");
ids.insert(serde_json::to_string(task_id).expect("task_id is a JSON scalar"));
let mut details = Map::new();
for (key, value) in task_object.expect("fields() accepted an object") {
if key != "task_id" {
details.insert(key.clone(), value.clone());
}
}
report(&Value::Object(details))?;
}
keys.clear();
let milestones = object["milestones"]
.as_array()
.ok_or_else(|| OutcomeError::new("Invalid task outcomes"))?;
let mut milestone_ids: HashSet<String> = HashSet::new();
for item in milestones {
let normalized = milestone(item)?;
let id = serde_json::to_string(&normalized.id).expect("milestone id is a JSON scalar");
require(milestone_ids.insert(id), "Duplicate milestone")?;
}
Ok(())
}
pub fn outcome_schema() -> Value {
let mut properties = Map::new();
for key in REPORT_FIELDS {
let mut field = Map::new();
field.insert("type".into(), json!(["string", "null"]));
field.insert("minLength".into(), json!(1));
field.insert(
"maxLength".into(),
json!(field_limit(key).expect("known report field")),
);
properties.insert(key.to_string(), Value::Object(field));
}
let mut schema = Map::new();
schema.insert("type".into(), json!("object"));
schema.insert("additionalProperties".into(), json!(false));
schema.insert("properties".into(), Value::Object(properties));
Value::Object(schema)
}
pub fn milestone_schema() -> Value {
let mut task_id = Map::new();
task_id.insert("type".into(), json!("string"));
task_id.insert("minLength".into(), json!(1));
task_id.insert("maxLength".into(), json!(160));
let mut criterion_id = Map::new();
criterion_id.insert("type".into(), json!("string"));
criterion_id.insert(
"pattern".into(),
json!("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$"),
);
let mut description = Map::new();
description.insert("type".into(), json!("string"));
description.insert("minLength".into(), json!(1));
description.insert("maxLength".into(), json!(1000));
let mut item_properties = Map::new();
item_properties.insert("task_id".into(), Value::Object(task_id));
item_properties.insert("criterion_id".into(), Value::Object(criterion_id));
item_properties.insert("description".into(), Value::Object(description));
let mut item = Map::new();
item.insert("type".into(), json!("object"));
item.insert("additionalProperties".into(), json!(false));
item.insert(
"required".into(),
json!(["task_id", "criterion_id", "description"]),
);
item.insert("properties".into(), Value::Object(item_properties));
let mut criteria = Map::new();
criteria.insert("type".into(), json!("array"));
criteria.insert("minItems".into(), json!(1));
criteria.insert("maxItems".into(), json!(MAX_CRITERIA));
criteria.insert("items".into(), Value::Object(item));
let mut id = Map::new();
id.insert("type".into(), json!("string"));
id.insert("minLength".into(), json!(1));
id.insert("maxLength".into(), json!(160));
let mut title = Map::new();
title.insert("type".into(), json!("string"));
title.insert("minLength".into(), json!(1));
title.insert("maxLength".into(), json!(1000));
let mut properties = Map::new();
properties.insert("id".into(), Value::Object(id));
properties.insert("title".into(), Value::Object(title));
properties.insert("criteria".into(), Value::Object(criteria));
let mut schema = Map::new();
schema.insert("type".into(), json!("object"));
schema.insert("additionalProperties".into(), json!(false));
schema.insert("required".into(), json!(["id", "title", "criteria"]));
schema.insert("properties".into(), Value::Object(properties));
Value::Object(schema)
}
fn read_json(input_file: &str, locale: crate::locale::Locale) -> Result<Value> {
let file = open_json_input(input_file).with_context(|| {
localized_format!(
locale,
"cannot read JSON {input_file}",
"无法读取 JSON {input_file}",
"JSON {input_file} を読み取れません"
)
})?;
read_json_file(file, input_file, locale)
}
fn read_json_file(
file: std::fs::File,
input_file: &str,
locale: crate::locale::Locale,
) -> Result<Value> {
const MAX_INPUT_BYTES: usize = 256 * 1024;
let metadata = file.metadata().with_context(|| {
localized_format!(
locale,
"cannot read JSON {input_file}",
"无法读取 JSON {input_file}",
"JSON {input_file} を読み取れません"
)
})?;
if !metadata.is_file() || metadata.len() > MAX_INPUT_BYTES as u64 {
bail!(crate::locale::diagnostic(
locale,
"Input JSON must be a regular file of at most 256 KiB"
)
.to_string());
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.take((MAX_INPUT_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.with_context(|| {
localized_format!(
locale,
"cannot read JSON {input_file}",
"无法读取 JSON {input_file}",
"JSON {input_file} を読み取れません"
)
})?;
if bytes.len() > MAX_INPUT_BYTES {
bail!(crate::locale::diagnostic(locale, "Input JSON exceeds 256 KiB").to_string());
}
let Ok(text) = std::str::from_utf8(&bytes) else {
bail!(crate::locale::diagnostic(locale, "Input JSON is invalid").to_string());
};
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
serde_json::from_str(text).map_err(|_| {
anyhow!(crate::locale::diagnostic(locale, "Input JSON is invalid").to_string())
})
}
#[cfg(unix)]
fn open_json_input(input_file: &str) -> std::io::Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_CLOEXEC | libc::O_NONBLOCK)
.open(input_file)
}
#[cfg(not(unix))]
fn open_json_input(input_file: &str) -> std::io::Result<std::fs::File> {
std::fs::File::open(input_file)
}
pub fn cmd_task_outcomes(
action: &str,
input_file: &str,
locale: crate::locale::Locale,
) -> Result<()> {
let document = read_json(input_file, locale)?;
let object = document.as_object().ok_or_else(|| {
anyhow!(crate::locale::diagnostic(locale, "Input JSON is invalid").to_string())
})?;
const CLI_KEYS: [&str; 3] = ["operation", "state", "input"];
if object.keys().any(|key| !CLI_KEYS.contains(&key.as_str())) {
bail!(crate::locale::diagnostic(locale, "Input JSON is invalid").to_string());
}
let operation = object
.get("operation")
.and_then(Value::as_str)
.unwrap_or("");
if operation != action {
bail!(crate::locale::diagnostic(
locale,
"Input JSON operation does not match the requested action"
)
.to_string());
}
let state = object
.get("state")
.filter(|value| value.is_object())
.ok_or_else(|| {
anyhow!(crate::locale::diagnostic(locale, "Input JSON is invalid").to_string())
})?;
let input = object.get("input").cloned().unwrap_or(Value::Null);
if action == "apply" && !input.is_object() {
bail!(crate::locale::diagnostic(locale, "Input JSON is invalid").to_string());
}
let mut output = Map::new();
output.insert("operation".into(), json!(action));
match action {
"apply" => {
let next = apply_task_outcomes(state, &input)?;
output.insert("applied".into(), json!(true));
output.insert(
"task_outcomes".into(),
match next {
Some(value) => value,
None => state
.as_object()
.and_then(|state| state.get("task_outcomes"))
.cloned()
.unwrap_or(Value::Null),
},
);
}
"validate" => {
validate_task_outcomes(state)?;
output.insert("valid".into(), json!(true));
}
"schema" => {
output.insert("outcome".into(), outcome_schema());
output.insert("milestone_contract".into(), milestone_schema());
}
other => bail!(
"{}",
localized_format!(
locale,
"unknown task-outcomes action {other}",
"未知的 task-outcomes 操作 {other}",
"不明なtask-outcomes操作 {other}"
)
),
}
let text =
serde_json::to_string(&Value::Object(output)).context("serialize task outcomes result")?;
let stdout = std::io::stdout();
let mut handle = stdout.lock();
writeln!(handle, "{text}")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn state() -> Value {
json!({
"schema": 1,
"tasks": [{ "id": "goal", "title": "Goal", "status": "done" }],
})
}
fn contract() -> Value {
json!({
"id": "M1",
"title": "Milestone",
"criteria": [
{ "task_id": "goal", "criterion_id": "value", "description": "value is right" },
],
})
}
fn state_with(task_outcomes: &Value) -> Value {
let mut state = state();
state["task_outcomes"] = task_outcomes.clone();
state
}
fn failing<T>(prefix: &str, result: OutcomeResult<T>) {
let error = result.err().expect("call should be refused");
assert!(
error.message.starts_with(prefix),
"expected {prefix}, got {}",
error.message
);
}
#[test]
fn text_matches_ecmascript_trim_and_utf16_length() {
assert!(!text(&json!("\u{feff}"), 10));
assert!(text(&json!("\u{0085}"), 10));
assert!(text(&json!("😀".repeat(80)), 160));
assert!(!text(&json!("😀".repeat(81)), 160));
}
#[test]
fn input_json_uses_the_open_file_if_its_path_is_replaced() {
let root = std::env::temp_dir().join(format!(
"rdsh-outcomes-read-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
std::fs::create_dir(&root).unwrap();
let path = root.join("input.json");
let original = r#"{"operation":"schema","state":{"schema":1,"tasks":[]}}"#;
std::fs::write(&path, original).unwrap();
let file = open_json_input(path.to_str().unwrap()).unwrap();
std::fs::rename(&path, root.join("opened.json")).unwrap();
std::fs::write(&path, "not the validated input").unwrap();
let parsed =
read_json_file(file, path.to_str().unwrap(), crate::locale::Locale::English).unwrap();
assert_eq!(parsed["operation"], "schema");
assert_eq!(parsed["state"]["tasks"], json!([]));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn apply_reports_nothing_without_outcome_or_contract() {
let result = apply_task_outcomes(&state(), &json!({ "id": "goal" })).unwrap();
assert!(result.is_none());
}
#[test]
fn apply_creates_and_patches_reports_like_the_js_module() {
let first = apply_task_outcomes(
&state(),
&json!({
"id": "goal",
"outcome": { "purpose": "ship", "owner": null },
}),
)
.unwrap()
.unwrap();
assert_eq!(
first,
json!({
"schema": 1,
"tasks": [{ "task_id": "goal", "purpose": "ship", "owner": null }],
"milestones": [],
})
);
let patched = apply_task_outcomes(
&state_with(&first),
&json!({
"id": "goal",
"outcome": { "latest_outcome": "done", "owner": null },
}),
)
.unwrap()
.unwrap();
assert_eq!(
patched,
json!({
"schema": 1,
"tasks": [{
"task_id": "goal",
"purpose": "ship",
"owner": null,
"latest_outcome": "done",
}],
"milestones": [],
})
);
}
#[test]
fn apply_rejects_forged_and_malformed_reports() {
failing(
"Invalid outcome report fields",
apply_task_outcomes(
&state(),
&json!({ "id": "goal", "outcome": { "verified": true } }),
),
);
failing(
"Invalid outcome owner",
apply_task_outcomes(
&state(),
&json!({ "id": "goal", "outcome": { "owner": "x".repeat(1001) } }),
),
);
failing(
"Invalid outcome purpose",
apply_task_outcomes(
&state(),
&json!({ "id": "goal", "outcome": { "purpose": " " } }),
),
);
}
#[test]
fn apply_freezes_milestone_criteria_under_one_id() {
let stored = apply_task_outcomes(
&state(),
&json!({ "id": "goal", "milestone": "M1", "milestone_contract": contract() }),
)
.unwrap()
.unwrap();
assert_eq!(stored["milestones"].as_array().unwrap().len(), 1);
let again = apply_task_outcomes(
&state_with(&stored),
&json!({ "id": "goal", "milestone": "M1", "milestone_contract": contract() }),
)
.unwrap()
.unwrap();
assert_eq!(again, stored);
failing(
"Milestone criteria are fixed",
apply_task_outcomes(
&state_with(&stored),
&json!({
"id": "goal",
"milestone": "M1",
"milestone_contract": {
"id": "M1",
"title": "Milestone",
"criteria": [{
"task_id": "goal",
"criterion_id": "other",
"description": "different",
}],
},
}),
),
);
failing(
"Duplicate milestone criterion",
apply_task_outcomes(
&state(),
&json!({
"id": "goal",
"milestone": "M1",
"milestone_contract": {
"id": "M1",
"title": "Milestone",
"criteria": [
{ "task_id": "goal", "criterion_id": "value", "description": "value is right" },
{ "task_id": "goal", "criterion_id": "value", "description": "value is right" },
],
},
}),
),
);
failing(
"Milestone contract must match the task milestone",
apply_task_outcomes(
&state(),
&json!({ "id": "goal", "milestone": "M2", "milestone_contract": contract() }),
),
);
}
#[test]
fn apply_rejects_stored_milestone_with_different_json_key_order() {
let stored: Value = serde_json::from_str(
r#"{"schema":1,"tasks":[],"milestones":[{"title":"Milestone","id":"M1","criteria":[{"task_id":"goal","criterion_id":"value","description":"value is right"}]}]}"#,
)
.unwrap();
failing(
"Milestone criteria are fixed",
apply_task_outcomes(
&state_with(&stored),
&json!({
"id": "goal",
"milestone": "M1",
"milestone_contract": contract(),
}),
),
);
}
#[test]
fn apply_returns_an_error_for_malformed_stored_outcomes() {
failing(
"Invalid task outcomes",
apply_task_outcomes(
&state_with(&json!({ "schema": 1, "tasks": "not an array", "milestones": [] })),
&json!({ "id": "goal", "outcome": { "purpose": "ship" } }),
),
);
}
#[test]
fn apply_uses_the_js_falsy_fallback_for_stored_outcomes() {
for falsy in [json!(false), json!(0), json!("")] {
let next = apply_task_outcomes(
&state_with(&falsy),
&json!({ "id": "goal", "outcome": { "purpose": "ship" } }),
)
.unwrap()
.unwrap();
assert_eq!(next["tasks"][0]["purpose"], "ship");
}
}
#[test]
fn validate_accepts_a_missing_document_and_the_applied_one() {
validate_task_outcomes(&state()).unwrap();
let stored = apply_task_outcomes(
&state(),
&json!({
"id": "goal",
"outcome": { "acceptance_task_id": "goal" },
"milestone": "M1",
"milestone_contract": contract(),
}),
)
.unwrap()
.unwrap();
let mut document = state();
document["task_outcomes"] = stored;
validate_task_outcomes(&document).unwrap();
}
#[test]
fn validate_rejects_unknown_tasks_and_duplicates() {
let mut document = state();
document["task_outcomes"] = json!({
"schema": 1,
"tasks": [{ "task_id": "ghost" }],
"milestones": [],
});
failing("Invalid outcome task", validate_task_outcomes(&document));
let mut duplicated = state();
duplicated["task_outcomes"] = json!({
"schema": 1,
"tasks": [{ "task_id": "goal" }, { "task_id": "goal" }],
"milestones": [],
});
failing("Invalid outcome task", validate_task_outcomes(&duplicated));
let mut extra = state();
extra["task_outcomes"] = json!({ "schema": 1, "tasks": [], "milestones": [], "extra": 1 });
failing("Invalid task outcomes", validate_task_outcomes(&extra));
}
#[test]
fn schemas_keep_the_advertised_shape() {
let outcome = outcome_schema();
assert_eq!(outcome["additionalProperties"], json!(false));
assert_eq!(outcome["properties"]["owner"]["maxLength"], json!(1000));
assert_eq!(
outcome["properties"]["purpose"]["type"],
json!(["string", "null"])
);
let milestone = milestone_schema();
assert_eq!(milestone["required"], json!(["id", "title", "criteria"]));
assert_eq!(milestone["properties"]["criteria"]["maxItems"], json!(50));
assert_eq!(
milestone["properties"]["criteria"]["items"]["properties"]["criterion_id"]["pattern"],
json!("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
);
}
#[test]
fn criterion_ids_follow_the_js_pattern() {
assert!(criterion_id_ok("a"));
assert!(criterion_id_ok("A1._-"));
assert!(!criterion_id_ok(""));
assert!(!criterion_id_ok("_a"));
assert!(!criterion_id_ok(&"a".repeat(129)));
assert!(criterion_id_ok(&"a".repeat(128)));
}
}