use std::collections::HashSet;
use std::path::Path;
use crate::error::MindError;
use crate::sanitize::{has_blocked_chars, strip_ansi};
use crate::workflow_meta::WorkflowMeta;
pub const SIZE_CAP: u64 = 524_288;
const MAX_NAMED_CLAIMANTS: usize = 3;
#[derive(Debug, Default, Clone)]
pub struct WorkflowRead {
pub meta: WorkflowMeta,
pub size: Option<u64>,
pub over_mind_cap: Option<u64>,
}
pub fn read(file: &Path) -> WorkflowRead {
let (meta, over_mind_cap) = match crate::workflow_meta::file_meta(file) {
Ok(meta) => (meta, None),
Err(MindError::MetadataTooLarge { limit, .. }) => (WorkflowMeta::default(), Some(limit)),
Err(_) => (WorkflowMeta::default(), None),
};
let size = std::fs::metadata(file).ok().map(|m| m.len());
WorkflowRead {
meta,
size,
over_mind_cap,
}
}
pub fn cap_notice(read: &WorkflowRead) -> Option<String> {
let limit = read.over_mind_cap?;
Some(format!(
"mind read no `meta` from this workflow: the file is over mind's own {} metadata read cap, \
so mind read none of it. That cap is mind's, not the harness's, and says nothing about \
whether the harness loads the file; raise it with `--max-metadata-size` to have mind read \
this workflow.",
crate::error::format_metadata_size(limit)
))
}
pub fn skip_reasons(read: &WorkflowRead) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let meta = &read.meta;
if read.over_mind_cap.is_none() {
if meta.is_empty() {
out.push(
"mind read no `name`, `description`, or `whenToUse` from its `meta`".to_string(),
);
} else {
for (key, value) in [
("name", meta.name.as_deref()),
("description", meta.description.as_deref()),
] {
match value {
None => out.push(format!("`meta.{key}` is missing")),
Some(v) if v.trim().is_empty() => out.push(format!("`meta.{key}` is empty")),
Some(v) if key == "name" && has_blocked_chars(v.trim()) => out.push(
"`meta.name` contains a control or invisible character, so mind cannot \
use it as a harness name"
.to_string(),
),
Some(_) => {}
}
}
}
}
if let Some(bytes) = read.size
&& bytes > SIZE_CAP
{
out.push(format!(
"the file is {bytes} bytes, over the harness's {SIZE_CAP}-byte cap"
));
}
out
}
pub fn harness_name(meta: &WorkflowMeta) -> Option<String> {
harness_name_with_bare(meta, &None, &HashSet::new(), &HashSet::new())
}
pub fn harness_name_with_bare(
meta: &WorkflowMeta,
prefix: &Option<String>,
siblings: &HashSet<String>,
bare_names: &HashSet<String>,
) -> Option<String> {
let raw = meta.name.as_deref()?;
let expanded = crate::namespace::expand(raw, prefix, siblings, bare_names).ok()?;
let trimmed = expanded.trim();
if trimmed.is_empty() || has_blocked_chars(trimmed) {
return None;
}
Some(trimmed.to_string())
}
pub fn divergence(
effective_name: &str,
bare_name: &str,
harness_name: Option<&str>,
) -> Option<String> {
let harness = harness_name?;
if harness == effective_name {
return None;
}
Some(format!(
"the harness resolves it as '{}', not '{}' -- it answers to its `meta.name`, not its \
file name (write `meta.name: '{{{{ns:{}}}}}'` to keep the two in step)",
strip_ansi(harness),
strip_ansi(effective_name),
strip_ansi(bare_name),
))
}
pub fn collision(harness_name: &str, others: &[String]) -> String {
collision_at(harness_name, others, Site::Installed)
}
#[derive(Clone, Copy)]
enum Site {
Installed,
Review,
}
fn collision_at(harness_name: &str, others: &[String], site: Site) -> String {
debug_assert!(
!others.is_empty(),
"collision() takes the OTHER claimants of a shared name; there is always at least one"
);
let listed = match others.len() > MAX_NAMED_CLAIMANTS {
true => format!(
"{}, and {} more",
others[..MAX_NAMED_CLAIMANTS].join(", "),
others.len() - MAX_NAMED_CLAIMANTS
),
false => others.join(", "),
};
let total = others.len() + 1;
let q = match total {
2 => "both".to_string(),
n => format!("all {n}"),
};
let tail = match site {
Site::Installed => {
format!("the harness sees one workflow under that name, and {q} are installed")
}
Site::Review => format!(
"the harness would see one workflow under that name, and mind would install {q}"
),
};
format!(
"it answers to the harness name '{}', which {listed} also claim{}: {tail}",
strip_ansi(harness_name),
match others.len() {
1 => "s",
_ => "",
},
)
}
pub fn claim_groups(claims: &[(String, String)]) -> Vec<(String, Vec<String>)> {
let mut order: Vec<&str> = Vec::new();
let mut by_name: std::collections::HashMap<&str, Vec<String>> =
std::collections::HashMap::new();
for (name, key) in claims {
let claimants = by_name.entry(name.as_str()).or_default();
if claimants.is_empty() {
order.push(name.as_str());
}
claimants.push(key.clone());
}
order
.into_iter()
.filter_map(|name| {
let claimants = by_name.remove(name)?;
match claimants.len() >= 2 {
true => Some((name.to_string(), claimants)),
false => None,
}
})
.collect()
}
pub fn collisions(claims: &[(String, String)]) -> Vec<(String, String)> {
claim_groups(claims)
.into_iter()
.map(|(name, claimants)| {
let others: Vec<String> = claimants[1..].iter().map(|k| strip_ansi(k)).collect();
(
claimants[0].clone(),
collision_at(&name, &others, Site::Review),
)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn meta(name: Option<&str>, description: Option<&str>) -> WorkflowMeta {
WorkflowMeta {
name: name.map(str::to_string),
description: description.map(str::to_string),
when_to_use: None,
}
}
fn read_of(meta: WorkflowMeta, size: Option<u64>) -> WorkflowRead {
WorkflowRead {
meta,
size,
over_mind_cap: None,
}
}
#[test]
fn a_loadable_workflow_has_no_skip_reasons() {
let m = meta(Some("review"), Some("Review the diff"));
assert!(skip_reasons(&read_of(m, Some(1024))).is_empty());
}
#[test]
fn an_unreadable_meta_is_a_single_skip_reason() {
let reasons = skip_reasons(&read_of(WorkflowMeta::default(), Some(10)));
assert_eq!(reasons.len(), 1, "{reasons:?}");
assert!(
reasons[0].contains("mind read no `name`, `description`, or `whenToUse`"),
"{reasons:?}"
);
}
#[test]
fn a_missing_name_and_an_empty_description_are_both_reported() {
let reasons = skip_reasons(&read_of(meta(None, Some(" ")), None));
assert_eq!(reasons.len(), 2, "{reasons:?}");
assert!(reasons[0].contains("`meta.name` is missing"), "{reasons:?}");
assert!(
reasons[1].contains("`meta.description` is empty"),
"{reasons:?}"
);
}
#[test]
fn a_file_over_the_cap_is_reported_by_itself() {
let m = meta(Some("review"), Some("Review the diff"));
assert!(
skip_reasons(&read_of(m.clone(), Some(SIZE_CAP))).is_empty(),
"cap is not <"
);
let reasons = skip_reasons(&read_of(m, Some(SIZE_CAP + 1)));
assert_eq!(reasons.len(), 1, "{reasons:?}");
assert!(reasons[0].contains("524288-byte cap"), "{reasons:?}");
}
#[test]
fn minds_own_cap_is_reported_apart_from_what_the_file_declares() {
let capped = WorkflowRead {
meta: WorkflowMeta::default(),
size: Some(SIZE_CAP + 1),
over_mind_cap: Some(2048),
};
let reasons = skip_reasons(&capped);
assert_eq!(reasons.len(), 1, "{reasons:?}");
assert!(reasons[0].contains("524288-byte cap"), "{reasons:?}");
assert!(
!reasons.iter().any(|r| r.contains("mind read no")),
"an unread file must not be reported as an empty one: {reasons:?}"
);
let notice = cap_notice(&capped).expect("the cap is reported on its own terms");
assert!(notice.contains("2 KiB"), "{notice}");
assert!(notice.contains("--max-metadata-size"), "{notice}");
assert!(
!notice.contains("will not load"),
"mind read nothing, so it claims nothing about the harness: {notice}"
);
assert_eq!(
cap_notice(&read_of(WorkflowMeta::default(), Some(10))),
None
);
}
#[test]
fn reading_an_absent_file_yields_an_empty_meta() {
let r = read(Path::new("./does-not-exist-workflow.js"));
assert!(r.meta.is_empty());
assert_eq!(r.size, None);
assert_eq!(r.over_mind_cap, None);
}
#[test]
fn an_invisible_character_in_a_meta_name_is_not_a_usable_name() {
let m = meta(Some("revi\u{200B}ew"), Some("Review the diff"));
assert_eq!(
harness_name(&m),
None,
"a name that prints as a different string is not one mind can use"
);
let reasons = skip_reasons(&read_of(m.clone(), Some(10)));
assert_eq!(reasons.len(), 1, "{reasons:?}");
assert!(
reasons[0].contains("control or invisible character"),
"{reasons:?}"
);
assert_eq!(
divergence("review", "review", harness_name(&m).as_deref()),
None
);
}
#[test]
fn a_padded_name_trims_while_an_interior_control_character_does_not() {
for padded in [" review ", "\treview\n", "review\n"] {
let m = meta(Some(padded), Some("d"));
assert_eq!(
harness_name(&m).as_deref(),
Some("review"),
"padding is trimmed, not a defect: {padded:?}"
);
assert!(
skip_reasons(&read_of(m.clone(), Some(10))).is_empty(),
"a padded name draws no reason: {padded:?}"
);
assert_eq!(
divergence("review", "review", harness_name(&m).as_deref()),
None,
"and it agrees with the item's own name: {padded:?}"
);
}
for hostile in [
"revi\tew",
"revi\u{1b}[31mew",
"revi\u{202E}ew",
"revi\u{200B}ew",
] {
let m = meta(Some(hostile), Some("d"));
assert_eq!(
harness_name(&m),
None,
"an interior control/invisible character is unusable: {hostile:?}"
);
let reasons = skip_reasons(&read_of(m, Some(10)));
assert!(
reasons.iter().any(|r| r.contains("control or invisible")),
"and is reported as its own reason: {hostile:?} -> {reasons:?}"
);
}
let reasons = skip_reasons(&read_of(meta(Some("\t "), Some("d")), Some(10)));
assert_eq!(reasons.len(), 1, "{reasons:?}");
assert!(reasons[0].contains("`meta.name` is empty"), "{reasons:?}");
let reasons = skip_reasons(&read_of(meta(Some("\u{200B}"), Some("d")), Some(10)));
assert_eq!(reasons.len(), 1, "{reasons:?}");
assert!(reasons[0].contains("control or invisible"), "{reasons:?}");
}
#[test]
fn an_unusable_name_does_not_suppress_the_other_reasons() {
let reasons = skip_reasons(&read_of(
meta(Some("revi\u{200B}ew"), None),
Some(SIZE_CAP + 1),
));
assert_eq!(reasons.len(), 3, "{reasons:?}");
assert!(reasons[0].contains("control or invisible"), "{reasons:?}");
assert!(
reasons[1].contains("`meta.description` is missing"),
"{reasons:?}"
);
assert!(reasons[2].contains("524288-byte cap"), "{reasons:?}");
}
#[test]
fn a_capped_read_with_no_size_reports_only_the_cap() {
let capped = WorkflowRead {
meta: WorkflowMeta::default(),
size: None,
over_mind_cap: Some(1024),
};
assert!(skip_reasons(&capped).is_empty(), "nothing is known");
assert!(cap_notice(&capped).is_some());
}
#[test]
fn the_claimant_cap_is_exclusive_at_its_boundary() {
let keys: Vec<String> = (0..4).map(|i| format!("workflow:w{i}")).collect();
let at = collision("deploy", &keys[..MAX_NAMED_CLAIMANTS]);
assert!(at.contains("workflow:w0, workflow:w1, workflow:w2"), "{at}");
assert!(!at.contains("more"), "exactly the cap names them all: {at}");
assert!(at.contains("also claim:"), "plural at three: {at}");
let over = collision("deploy", &keys);
assert!(over.contains("and 1 more"), "{over}");
assert!(!over.contains("workflow:w3"), "{over}");
}
#[test]
fn claim_groups_keep_first_claim_order_and_never_list_the_subject() {
let claims: Vec<(String, String)> = [
("ship", "workflow:zeta"),
("deploy", "workflow:mid"),
("ship", "workflow:alpha"),
("solo", "workflow:only"),
("deploy", "workflow:other"),
]
.into_iter()
.map(|(n, k)| (n.to_string(), k.to_string()))
.collect();
let groups = claim_groups(&claims);
assert_eq!(
groups
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<&str>>(),
vec!["ship", "deploy"],
"first-claim order, and `solo` is no group: {groups:?}"
);
assert_eq!(groups[0].1, vec!["workflow:zeta", "workflow:alpha"]);
let reports = collisions(&claims);
assert_eq!(reports.len(), 2, "{reports:?}");
assert_eq!(
reports[0].0, "workflow:zeta",
"the first claimant is subject"
);
assert!(
reports[0].1.contains("workflow:alpha also claims"),
"{}",
reports[0].1
);
for (subject, msg) in &reports {
assert!(
!msg.contains(&format!("{subject} also")),
"the subject must not be listed among the others: {msg}"
);
}
assert!(claim_groups(&[]).is_empty());
assert!(collisions(&[]).is_empty());
}
#[test]
fn grouping_compares_exact_names_not_displayed_ones() {
let claims: Vec<(String, String)> = [
("deploy", "workflow:a"),
("deploy ", "workflow:b"),
("Deploy", "workflow:c"),
]
.into_iter()
.map(|(n, k)| (n.to_string(), k.to_string()))
.collect();
assert!(
claim_groups(&claims).is_empty(),
"three distinct strings are three names"
);
}
#[test]
fn a_tokenized_meta_name_matches_the_prefixed_effective_name() {
let m = meta(Some("{{ns:review}}"), Some("d"));
let siblings: HashSet<String> = ["review".to_string()].into_iter().collect();
let name = harness_name_with_bare(&m, &Some("jk".to_string()), &siblings, &HashSet::new());
assert_eq!(name.as_deref(), Some("jk:review"));
assert_eq!(divergence("jk:review", "review", name.as_deref()), None);
let installed = meta(Some("jk:review"), Some("d"));
assert_eq!(harness_name(&installed).as_deref(), Some("jk:review"));
}
#[test]
fn a_tokenized_meta_name_expands_bare_without_a_prefix() {
let m = meta(Some("{{ns:review}}"), Some("d"));
let siblings: HashSet<String> = ["review".to_string()].into_iter().collect();
let name = harness_name_with_bare(&m, &None, &siblings, &HashSet::new());
assert_eq!(name.as_deref(), Some("review"));
assert_eq!(divergence("review", "review", name.as_deref()), None);
}
#[test]
fn a_literal_meta_name_diverges_from_a_prefixed_effective_name() {
let m = meta(Some("review"), Some("d"));
let name = harness_name_with_bare(
&m,
&Some("jk".to_string()),
&HashSet::new(),
&HashSet::new(),
);
assert_eq!(name.as_deref(), Some("review"));
let msg = divergence("jk:review", "review", name.as_deref()).expect("diverges");
assert!(msg.contains("'review'"), "{msg}");
assert!(msg.contains("'jk:review'"), "{msg}");
assert!(
msg.contains("{{ns:review}}"),
"the remedy must be a token that resolves: {msg}"
);
assert!(
!msg.contains("{{ns:jk:review}}"),
"a prefixed referent resolves to no sibling (NS-11): {msg}"
);
}
#[test]
fn an_unreadable_name_does_not_also_report_a_divergence() {
let m = meta(None, Some("d"));
assert_eq!(harness_name(&m), None);
assert_eq!(divergence("review", "review", None), None);
}
#[test]
fn an_unresolvable_token_in_a_meta_name_yields_no_harness_name() {
let m = meta(Some("{{ns:typo}}"), Some("d"));
assert_eq!(
harness_name_with_bare(&m, &None, &HashSet::new(), &HashSet::new()),
None
);
}
#[test]
fn an_agent_referent_in_a_meta_name_expands_bare_under_a_prefix() {
let m = meta(Some("{{ns:review}}"), Some("d"));
let siblings: HashSet<String> = ["review".to_string()].into_iter().collect();
let bare: HashSet<String> = ["review".to_string()].into_iter().collect();
let name = harness_name_with_bare(&m, &Some("jk".to_string()), &siblings, &bare);
assert_eq!(name.as_deref(), Some("review"));
assert_eq!(divergence("review", "review", name.as_deref()), None);
}
#[test]
fn the_collision_message_names_every_other_claimant() {
let one = collision("review", &["workflow:jk:review".to_string()]);
assert!(one.contains("workflow:jk:review also claims"), "{one}");
let two = collision(
"review",
&["workflow:a".to_string(), "workflow:b".to_string()],
);
assert!(two.contains("workflow:a, workflow:b also claim:"), "{two}");
}
#[test]
fn a_collision_message_caps_the_claimants_it_names() {
let others: Vec<String> = (0..300).map(|i| format!("workflow:w{i:03}")).collect();
let msg = collision("deploy", &others);
assert!(
msg.contains("workflow:w000, workflow:w001, workflow:w002"),
"{msg}"
);
assert!(msg.contains("and 297 more"), "{msg}");
assert!(
!msg.contains("workflow:w003"),
"the fourth claimant is counted, not named: {msg}"
);
assert!(msg.len() < 400, "the message must stay bounded: {msg}");
}
#[test]
fn claims_group_by_name_into_one_report_each() {
let mut claims: Vec<(String, String)> = (0..300)
.map(|i| ("deploy".to_string(), format!("workflow:w{i:03}")))
.collect();
claims.push(("lonely".to_string(), "workflow:solo".to_string()));
claims.push(("ship".to_string(), "workflow:a".to_string()));
claims.push(("ship".to_string(), "workflow:b".to_string()));
let groups = claim_groups(&claims);
assert_eq!(groups.len(), 2, "one group per colliding name: {groups:?}");
assert_eq!(groups[0].0, "deploy");
assert_eq!(groups[0].1.len(), 300);
assert_eq!(groups[1].0, "ship");
let reports = collisions(&claims);
assert_eq!(reports.len(), 2, "{reports:?}");
assert_eq!(
reports[0].0, "workflow:w000",
"the first claimant is the subject"
);
assert!(reports[0].1.contains("and 296 more"), "{}", reports[0].1);
assert_eq!(reports[1].0, "workflow:a");
assert!(
reports[1].1.contains("workflow:b also claims"),
"{}",
reports[1].1
);
}
#[test]
fn the_collision_tail_is_count_aware_and_site_aware() {
let one = collision("d", &["workflow:a".to_string()]);
assert!(one.ends_with("and both are installed"), "{one}");
let three: Vec<String> = (0..3).map(|i| format!("workflow:w{i}")).collect();
let four = collision("d", &three);
assert!(four.ends_with("and all 4 are installed"), "{four}");
assert!(!four.contains("both"), "{four}");
let claims2: Vec<(String, String)> = vec![
("d".into(), "workflow:a".into()),
("d".into(), "workflow:b".into()),
];
let r2 = &collisions(&claims2)[0].1;
assert!(
r2.ends_with("and mind would install both"),
"review wording: {r2}"
);
assert!(r2.contains("the harness would see one"), "{r2}");
assert!(!r2.contains("are installed"), "{r2}");
let claims4: Vec<(String, String)> = (0..4)
.map(|i| ("d".to_string(), format!("workflow:w{i}")))
.collect();
let r4 = &collisions(&claims4)[0].1;
assert!(r4.ends_with("and mind would install all 4"), "{r4}");
assert!(!r4.contains("are installed"), "{r4}");
}
#[test]
fn three_claimants_say_all_3_not_both_at_either_site() {
let others = vec!["workflow:a".to_string(), "workflow:b".to_string()];
let inst = collision("d", &others);
assert!(inst.ends_with("and all 3 are installed"), "{inst}");
assert!(!inst.contains("both"), "{inst}");
let claims: Vec<(String, String)> = ["workflow:x", "workflow:a", "workflow:b"]
.iter()
.map(|k| ("d".to_string(), k.to_string()))
.collect();
let rev = &collisions(&claims)[0].1;
assert!(rev.ends_with("and mind would install all 3"), "{rev}");
assert!(!rev.contains("both"), "{rev}");
let head = |s: &str| s.split(": ").next().unwrap().to_string();
assert!(head(&inst).starts_with("it answers to the harness name 'd'"));
assert!(rev.starts_with("it answers to the harness name 'd', which"));
}
#[test]
fn a_meta_name_is_sanitized_into_every_message() {
let msg = divergence("review", "review", Some("ev\u{1b}[31mil")).expect("diverges");
assert!(!msg.contains('\u{1b}'), "{msg:?}");
let msg = collision("ev\u{1b}[31mil", &["workflow:x".to_string()]);
assert!(!msg.contains('\u{1b}'), "{msg:?}");
}
}