use crate::model::SavedFlow;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Requires {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub packs: Vec<PackRequirement>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<String>,
}
impl Requires {
pub fn is_empty(&self) -> bool {
self.packs.is_empty() && self.tools.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PackRequirement {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub optional: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_version: Option<String>,
}
fn is_false(b: &bool) -> bool {
!*b
}
impl PackRequirement {
pub fn new(id: impl Into<String>) -> Self {
PackRequirement {
id: id.into(),
version: None,
content_sha256: None,
reason: None,
optional: false,
resolved_version: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AvailablePack {
pub id: String,
pub version: String,
pub content_sha256: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Unmet {
MissingPack {
id: String,
optional: bool,
},
VersionConflict {
id: String,
need: String,
have: String,
optional: bool,
},
HashMismatch {
id: String,
need: String,
have: Option<String>,
optional: bool,
},
MissingTool {
name: String,
},
}
impl Unmet {
pub fn is_optional(&self) -> bool {
match self {
Unmet::MissingPack { optional, .. }
| Unmet::VersionConflict { optional, .. }
| Unmet::HashMismatch { optional, .. } => *optional,
Unmet::MissingTool { .. } => false,
}
}
}
pub fn derive_requires(flow: &SavedFlow) -> Requires {
use crate::model::{CoreNodeType, FlowNodeType};
let mut pack_ids: BTreeSet<String> = BTreeSet::new();
let mut tools: BTreeSet<String> = BTreeSet::new();
for node in &flow.flow.nodes {
if let Some(pack) = node.data.get("pack").and_then(|v| v.as_str())
&& !pack.is_empty()
{
pack_ids.insert(pack.to_string());
}
match &node.node_type {
FlowNodeType::Custom(s) => {
if let Some((vendor, _)) = s.split_once(':')
&& !vendor.is_empty()
{
pack_ids.insert(vendor.to_string());
}
}
FlowNodeType::Core(CoreNodeType::Tool) => {
if let Some(name) = node.data.get("tool_name").and_then(|v| v.as_str())
&& !name.is_empty()
{
tools.insert(name.to_string());
}
}
FlowNodeType::Core(_) => {}
}
}
Requires {
packs: pack_ids.into_iter().map(PackRequirement::new).collect(),
tools: tools.into_iter().collect(),
}
}
pub fn check_requirements(req: &Requires, available: &[AvailablePack]) -> Vec<Unmet> {
let mut unmet = Vec::new();
for pr in &req.packs {
let Some(found) = available.iter().find(|a| a.id == pr.id) else {
unmet.push(Unmet::MissingPack {
id: pr.id.clone(),
optional: pr.optional,
});
continue;
};
if let Some(range) = &pr.version {
let satisfied = match (
semver::VersionReq::parse(range),
semver::Version::parse(&found.version),
) {
(Ok(vr), Ok(v)) => vr.matches(&v),
_ => false,
};
if !satisfied {
unmet.push(Unmet::VersionConflict {
id: pr.id.clone(),
need: range.clone(),
have: found.version.clone(),
optional: pr.optional,
});
continue;
}
}
if let Some(need) = &pr.content_sha256
&& found.content_sha256.as_deref() != Some(need.as_str())
{
unmet.push(Unmet::HashMismatch {
id: pr.id.clone(),
need: need.clone(),
have: found.content_sha256.clone(),
optional: pr.optional,
});
}
}
unmet
}
pub fn check_tools(req: &Requires, available_tools: &[String]) -> Vec<Unmet> {
req.tools
.iter()
.filter(|name| !available_tools.iter().any(|a| a == *name))
.map(|name| Unmet::MissingTool { name: name.clone() })
.collect()
}
pub(crate) fn is_valid_pack_id(id: &str) -> bool {
if id.is_empty() || id.len() > 64 {
return false;
}
let mut chars = id.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return false;
}
id.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
pub(crate) fn is_valid_sha256(s: &str) -> bool {
s.len() == 64
&& s.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{CoreNodeType, FlowDefinition, FlowNode, FlowNodeType, SavedFlow};
use serde_json::json;
fn saved_with(nodes: Vec<FlowNode>) -> SavedFlow {
SavedFlow {
spec_version: "2".into(),
id: "f".into(),
name: "F".into(),
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
enabled: false,
schedules: vec![],
requires: None,
flow: FlowDefinition {
nodes,
edges: vec![],
},
}
}
fn node(id: &str, ty: FlowNodeType, data: serde_json::Value) -> FlowNode {
FlowNode {
id: id.into(),
node_type: ty,
data,
position: [0.0, 0.0],
}
}
#[test]
fn derive_finds_packs_and_tools_sorted_deduped() {
let sf = saved_with(vec![
node(
"t1",
FlowNodeType::Core(CoreNodeType::Tool),
json!({ "tool_name": "cloudflare_purge_cache" }),
),
node(
"t2",
FlowNodeType::Core(CoreNodeType::Tool),
json!({ "tool_name": "cloudflare_list_zones" }),
),
node(
"sa",
FlowNodeType::Core(CoreNodeType::SubAgent),
json!({ "task": "do it", "pack": "cloudflare", "tool_set": "all" }),
),
node(
"cust",
FlowNodeType::Custom("linear:create_issue".into()),
json!({}),
),
]);
let req = derive_requires(&sf);
let ids: Vec<&str> = req.packs.iter().map(|p| p.id.as_str()).collect();
assert_eq!(ids, vec!["cloudflare", "linear"]);
assert_eq!(
req.tools,
vec![
"cloudflare_list_zones".to_string(),
"cloudflare_purge_cache".to_string()
]
);
}
#[test]
fn check_missing_and_satisfied() {
let req = Requires {
packs: vec![PackRequirement {
id: "cloudflare".into(),
version: Some(">=1.2.0, <2.0.0".into()),
..PackRequirement::new("cloudflare")
}],
tools: vec![],
};
let unmet = check_requirements(&req, &[]);
assert!(matches!(unmet.as_slice(), [Unmet::MissingPack { id, .. }] if id == "cloudflare"));
let ok = check_requirements(
&req,
&[AvailablePack {
id: "cloudflare".into(),
version: "1.3.1".into(),
content_sha256: None,
}],
);
assert!(ok.is_empty(), "{ok:?}");
let conflict = check_requirements(
&req,
&[AvailablePack {
id: "cloudflare".into(),
version: "2.0.0".into(),
content_sha256: None,
}],
);
assert!(matches!(conflict.as_slice(), [Unmet::VersionConflict { .. }]));
}
#[test]
fn check_hash_pin() {
let hash = "a".repeat(64);
let req = Requires {
packs: vec![PackRequirement {
id: "cloudflare".into(),
content_sha256: Some(hash.clone()),
..PackRequirement::new("cloudflare")
}],
tools: vec![],
};
let mismatch = check_requirements(
&req,
&[AvailablePack {
id: "cloudflare".into(),
version: "1.0.0".into(),
content_sha256: Some("b".repeat(64)),
}],
);
assert!(matches!(mismatch.as_slice(), [Unmet::HashMismatch { .. }]));
let ok = check_requirements(
&req,
&[AvailablePack {
id: "cloudflare".into(),
version: "1.0.0".into(),
content_sha256: Some(hash),
}],
);
assert!(ok.is_empty());
}
#[test]
fn check_tools_reports_missing() {
let req = Requires {
packs: vec![],
tools: vec!["a_tool".into(), "b_tool".into()],
};
let unmet = check_tools(&req, &["a_tool".to_string()]);
assert!(matches!(unmet.as_slice(), [Unmet::MissingTool { name }] if name == "b_tool"));
}
#[test]
fn optional_flag_surfaces_on_unmet() {
let req = Requires {
packs: vec![PackRequirement {
optional: true,
..PackRequirement::new("maybe")
}],
tools: vec![],
};
let unmet = check_requirements(&req, &[]);
assert_eq!(unmet.len(), 1);
assert!(unmet[0].is_optional());
}
#[test]
fn id_and_hash_validation() {
assert!(is_valid_pack_id("cloudflare"));
assert!(is_valid_pack_id("metalcraft-calendar"));
assert!(is_valid_pack_id("digitalocean_spaces"));
assert!(!is_valid_pack_id(""));
assert!(!is_valid_pack_id("-leading-dash"));
assert!(!is_valid_pack_id("Capital"));
assert!(!is_valid_pack_id(&"x".repeat(65)));
assert!(is_valid_sha256(&"a1b2c3d4".repeat(8)));
assert!(!is_valid_sha256("short"));
assert!(!is_valid_sha256(&"A".repeat(64))); assert!(!is_valid_sha256(&"g".repeat(64))); }
}