use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::error::{
OlError, OL_4273_MANIFEST_UNREADABLE, OL_4320_PROVIDER_SCHEMA_INVALID,
OL_4321_TOOL_SCHEMA_INVALID, OL_4322_AMBIGUOUS_TOOL_REF, OL_4323_UNRESOLVED_TOOL_REF,
OL_4324_DUPLICATE_TOOL_REGISTRY, OL_4325_TOOL_PATHS_ZERO_MATCH,
OL_4326_OVERRIDE_COMMAND_CONFLICT, OL_4327_V1_MANIFEST_REJECTED,
OL_4328_TOOL_MANIFEST_MULTI_EDITOR,
};
use crate::generated::{
BindingV2, HealthCheckV2, ManifestEditor, ManifestProviderV2, ManifestToolV2,
ProcessOverrideV2, ProcessOverrideV2HealthCheck, ProcessV2, ToolItemV2,
};
use crate::manifest::schema;
const MAX_MANIFEST_BYTES: usize = 256 * 1024;
const MAX_TOOL_MANIFESTS: usize = 256;
#[derive(Debug, Clone)]
pub struct ResolvedManifest {
pub provider: ManifestProviderV2,
pub provider_path: PathBuf,
pub tools: BTreeMap<(String, String, String), ResolvedTool>,
pub bindings: Vec<ResolvedBinding>,
pub synth: crate::generated::Manifest,
}
impl ResolvedManifest {
pub fn binding_dir(&self, idx: usize) -> Option<PathBuf> {
let b = self.bindings.get(idx)?;
let key = (
b.editor_slug.clone(),
b.tool_slug.clone(),
b.version.clone(),
);
self.tools.get(&key).and_then(|t| {
t.manifest_path
.parent()
.map(Path::to_path_buf)
.or_else(|| Some(PathBuf::from(".")))
})
}
}
#[derive(Debug, Clone)]
pub struct ResolvedTool {
pub manifest_path: PathBuf,
pub editor_slug: String,
pub editor: ManifestEditor,
pub tool: ToolItemV2,
}
#[derive(Debug, Clone)]
pub struct ResolvedBinding {
pub editor_slug: String,
pub tool_slug: String,
pub version: String,
pub provider_slug: String,
}
pub fn peek_kind(bytes: &[u8]) -> Result<(Option<i64>, Option<String>), OlError> {
let yaml: serde_yaml::Value = serde_yaml::from_slice(bytes)
.map_err(|e| OlError::new(OL_4320_PROVIDER_SCHEMA_INVALID, format!("YAML parse: {e}")))?;
let schema_version = yaml.get("schema_version").and_then(|v| v.as_i64());
let kind = yaml
.get("kind")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok((schema_version, kind))
}
pub fn load_provider_tree(provider_path: &Path) -> Result<ResolvedManifest, OlError> {
let provider_bytes = read_capped(provider_path)?;
let provider = parse_provider_v2(&provider_bytes, provider_path)?;
let provider_dir = provider_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let mut tool_paths_expanded: Vec<PathBuf> = Vec::new();
for entry in provider.tool_paths.iter() {
let pattern: &str = entry.as_str();
let matched = expand_glob(&provider_dir, pattern)?;
if matched.is_empty() {
return Err(OlError::new(
OL_4325_TOOL_PATHS_ZERO_MATCH,
format!(
"tool_paths entry `{}` expanded to zero matches (relative to `{}`)",
pattern,
provider_dir.display()
),
)
.with_suggestion(
"Check the glob pattern; or remove the entry if no tools live under it.",
));
}
for p in matched {
if !tool_paths_expanded.contains(&p) {
tool_paths_expanded.push(p);
}
}
}
tool_paths_expanded.sort();
if tool_paths_expanded.len() > MAX_TOOL_MANIFESTS {
return Err(OlError::new(
OL_4325_TOOL_PATHS_ZERO_MATCH,
format!(
"tool_paths expanded to {} manifests (cap {}); narrow the glob",
tool_paths_expanded.len(),
MAX_TOOL_MANIFESTS
),
));
}
let mut tools: BTreeMap<(String, String, String), ResolvedTool> = BTreeMap::new();
for path in &tool_paths_expanded {
let bytes = read_capped(path)?;
let tool_manifest = parse_tool_v2(&bytes, path)?;
let editor_slug = tool_manifest.editor.slug.to_string();
let editor = tool_manifest.editor.clone();
for item in tool_manifest.tools.into_iter() {
let tool_slug = item.slug.to_string();
let version = item.version.to_string();
let key = (editor_slug.clone(), tool_slug.clone(), version.clone());
if tools.contains_key(&key) {
return Err(OlError::new(
OL_4324_DUPLICATE_TOOL_REGISTRY,
format!(
"duplicate tool registry entry `{}/{}@{}` (already loaded; second match in `{}`)",
editor_slug,
tool_slug,
version,
path.display()
),
));
}
tools.insert(
key,
ResolvedTool {
manifest_path: path.clone(),
editor_slug: editor_slug.clone(),
editor: editor.clone(),
tool: item,
},
);
}
}
let mut resolved_bindings: Vec<ResolvedBinding> = Vec::new();
let mut synth_bindings_json: Vec<serde_json::Value> = Vec::new();
let mut synth_tools_json: Vec<serde_json::Value> = Vec::new();
let mut synth_tools_seen: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
let mut synth_editor: Option<serde_json::Value> = None;
for (idx, binding) in provider.bindings.iter().enumerate() {
if let Some(ovr) = binding.process_override.as_ref() {
if !ovr.command.is_empty() && !ovr.command_args.is_empty() {
return Err(OlError::new(
OL_4326_OVERRIDE_COMMAND_CONFLICT,
format!(
"binding #{idx} `{}/{}`: process_override.command and \
process_override.command_args are mutually exclusive",
binding.tool, binding.provider
),
));
}
}
let (editor_slug, tool_slug, version) = resolve_ref(&binding.tool, &tools)?;
let resolved_tool = tools
.get(&(editor_slug.clone(), tool_slug.clone(), version.clone()))
.ok_or_else(|| {
OlError::new(
OL_4323_UNRESOLVED_TOOL_REF,
format!(
"tool ref `{}` resolved to ({}/{}@{}) but registry has no entry",
binding.tool, editor_slug, tool_slug, version
),
)
})?;
let merged_process = merge_process(
&resolved_tool.tool.process,
binding.process_override.as_ref(),
)?;
synth_bindings_json.push(synthesize_v1_binding(binding, &tool_slug, &merged_process));
if synth_tools_seen.insert(tool_slug.clone()) {
synth_tools_json.push(synthesize_v1_tool(&resolved_tool.tool));
}
if synth_editor.is_none() {
synth_editor = Some(serde_json::to_value(&resolved_tool.editor).map_err(|e| {
OlError::new(
OL_4320_PROVIDER_SCHEMA_INVALID,
format!("internal: serialize editor for synth: {e}"),
)
})?);
}
resolved_bindings.push(ResolvedBinding {
editor_slug,
tool_slug,
version,
provider_slug: binding.provider.clone(),
});
}
if synth_editor.is_none() {
if let Some((_, t)) = tools.iter().next() {
synth_editor = Some(serde_json::to_value(&t.editor).map_err(|e| {
OlError::new(
OL_4320_PROVIDER_SCHEMA_INVALID,
format!("internal: serialize fallback editor: {e}"),
)
})?);
} else {
return Err(OlError::new(
OL_4320_PROVIDER_SCHEMA_INVALID,
"provider manifest has no resolvable tools (tool_paths empty and bindings empty)",
));
}
}
let synth_providers_json: Vec<serde_json::Value> = provider
.providers
.iter()
.map(|p| serde_json::to_value(p).unwrap_or(serde_json::Value::Null))
.collect();
let synth_json = serde_json::json!({
"schema_version": 1,
"editor": synth_editor.expect("synth_editor populated above"),
"tools": synth_tools_json,
"providers": synth_providers_json,
"bindings": synth_bindings_json,
});
schema::validate(&synth_json).map_err(|e| {
OlError::new(
OL_4320_PROVIDER_SCHEMA_INVALID,
format!(
"internal: synthesized v1 manifest failed v1 schema validation \
(please report): {e}"
),
)
})?;
let synth: crate::generated::Manifest = serde_json::from_value(synth_json).map_err(|e| {
OlError::new(
OL_4320_PROVIDER_SCHEMA_INVALID,
format!("internal: synth manifest deserialize failed: {e}"),
)
})?;
Ok(ResolvedManifest {
provider,
provider_path: provider_path.to_path_buf(),
tools,
bindings: resolved_bindings,
synth,
})
}
pub fn parse_provider_v2(bytes: &[u8], path: &Path) -> Result<ManifestProviderV2, OlError> {
let yaml: serde_yaml::Value = serde_yaml::from_slice(bytes).map_err(|e| {
OlError::new(
OL_4320_PROVIDER_SCHEMA_INVALID,
format!("`{}`: YAML parse: {e}", path.display()),
)
})?;
let json = serde_json::to_value(&yaml).map_err(|e| {
OlError::new(
OL_4320_PROVIDER_SCHEMA_INVALID,
format!("`{}`: YAML→JSON: {e}", path.display()),
)
})?;
let kind = json.get("kind").and_then(|v| v.as_str());
let schema_version = json.get("schema_version").and_then(|v| v.as_i64());
if kind != Some("Provider") {
if kind.is_none() && schema_version == Some(1) {
return Err(OlError::new(
OL_4327_V1_MANIFEST_REJECTED,
format!(
"`{}` is a v1 manifest; v2 entrypoints require `kind: Provider`",
path.display()
),
)
.with_suggestion(
"Run `openlatch-provider migrate <v1-file> --out-tool ... --out-provider ...` \
to split it into the v2 shape.",
));
}
return Err(OlError::new(
OL_4320_PROVIDER_SCHEMA_INVALID,
format!(
"`{}`: expected `kind: Provider`, got `kind: {}`",
path.display(),
kind.unwrap_or("<missing>")
),
));
}
schema::validate_provider_v2(&json).map_err(|e| {
OlError::new(
OL_4320_PROVIDER_SCHEMA_INVALID,
format!("`{}`: {e}", path.display()),
)
})?;
let parsed: ManifestProviderV2 = serde_json::from_value(json).map_err(|e| {
OlError::new(
OL_4320_PROVIDER_SCHEMA_INVALID,
format!("`{}`: typify deserialize: {e}", path.display()),
)
})?;
Ok(parsed)
}
pub fn parse_tool_v2(bytes: &[u8], path: &Path) -> Result<ManifestToolV2, OlError> {
let yaml: serde_yaml::Value = serde_yaml::from_slice(bytes).map_err(|e| {
OlError::new(
OL_4321_TOOL_SCHEMA_INVALID,
format!("`{}`: YAML parse: {e}", path.display()),
)
})?;
let json = serde_json::to_value(&yaml).map_err(|e| {
OlError::new(
OL_4321_TOOL_SCHEMA_INVALID,
format!("`{}`: YAML→JSON: {e}", path.display()),
)
})?;
let kind = json.get("kind").and_then(|v| v.as_str());
let schema_version = json.get("schema_version").and_then(|v| v.as_i64());
if kind != Some("Tool") {
if kind.is_none() && schema_version == Some(1) {
return Err(OlError::new(
OL_4327_V1_MANIFEST_REJECTED,
format!(
"`{}` is a v1 manifest; v2 tool entrypoints require `kind: Tool`",
path.display()
),
));
}
return Err(OlError::new(
OL_4321_TOOL_SCHEMA_INVALID,
format!(
"`{}`: expected `kind: Tool`, got `kind: {}`",
path.display(),
kind.unwrap_or("<missing>")
),
));
}
if let Some(editor) = json.get("editor") {
if editor.is_array() {
return Err(OlError::new(
OL_4328_TOOL_MANIFEST_MULTI_EDITOR,
format!(
"`{}`: tool manifest declares multiple editors; one editor per tool \
manifest is the rule",
path.display()
),
));
}
}
schema::validate_tool_v2(&json).map_err(|e| {
OlError::new(
OL_4321_TOOL_SCHEMA_INVALID,
format!("`{}`: {e}", path.display()),
)
})?;
let parsed: ManifestToolV2 = serde_json::from_value(json).map_err(|e| {
OlError::new(
OL_4321_TOOL_SCHEMA_INVALID,
format!("`{}`: typify deserialize: {e}", path.display()),
)
})?;
Ok(parsed)
}
fn resolve_ref(
raw: &str,
tools: &BTreeMap<(String, String, String), ResolvedTool>,
) -> Result<(String, String, String), OlError> {
let (head, version) = match raw.rsplit_once('@') {
Some((h, v)) if !h.is_empty() && !v.is_empty() => (h, v),
_ => {
return Err(OlError::new(
OL_4323_UNRESOLVED_TOOL_REF,
format!(
"tool ref `{raw}` is malformed; expected `<editor>/<tool>@<version>` \
or `<tool>@<version>`"
),
)
.with_suggestion("Add `@<semver>` or `@latest`."));
}
};
let (editor_part, tool_part) = match head.split_once('/') {
Some((e, t)) if !e.is_empty() && !t.is_empty() => (Some(e), t),
Some(_) => {
return Err(OlError::new(
OL_4323_UNRESOLVED_TOOL_REF,
format!("tool ref `{raw}` has empty editor or tool slug"),
));
}
None => (None, head),
};
let resolve_version = |candidates: &[&(String, String, String)]| -> Result<String, OlError> {
if version != "latest" {
for c in candidates {
if c.2 == version {
return Ok(version.to_string());
}
}
Err(OlError::new(
OL_4323_UNRESOLVED_TOOL_REF,
format!("tool ref `{raw}`: no local manifest declares version `{version}`"),
))
} else {
let mut best: Option<(semver::Version, String)> = None;
for c in candidates {
if let Ok(parsed) = semver::Version::parse(&c.2) {
match &best {
None => best = Some((parsed, c.2.clone())),
Some((b, _)) if parsed > *b => best = Some((parsed, c.2.clone())),
_ => {}
}
}
}
best.map(|(_, s)| s).ok_or_else(|| {
OlError::new(
OL_4323_UNRESOLVED_TOOL_REF,
format!("tool ref `{raw}@latest`: no parseable semver in the local registry"),
)
})
}
};
if let Some(editor) = editor_part {
let candidates: Vec<&(String, String, String)> = tools
.keys()
.filter(|(e, t, _)| e == editor && t == tool_part)
.collect();
if candidates.is_empty() {
return Err(OlError::new(
OL_4323_UNRESOLVED_TOOL_REF,
format!(
"tool ref `{raw}`: no local manifest declares editor=`{editor}` + tool=`{tool_part}`"
),
));
}
let v = resolve_version(&candidates)?;
Ok((editor.to_string(), tool_part.to_string(), v))
} else {
let candidates: Vec<&(String, String, String)> =
tools.keys().filter(|(_, t, _)| t == tool_part).collect();
if candidates.is_empty() {
return Err(OlError::new(
OL_4323_UNRESOLVED_TOOL_REF,
format!("tool ref `{raw}`: no local manifest declares tool=`{tool_part}`"),
));
}
let mut editors: std::collections::BTreeSet<&str> =
candidates.iter().map(|(e, _, _)| e.as_str()).collect();
if editors.len() > 1 {
let list: Vec<&str> = editors.iter().copied().collect();
return Err(OlError::new(
OL_4322_AMBIGUOUS_TOOL_REF,
format!(
"tool ref `{raw}` is editor-implicit but matches multiple editors: {}",
list.join(", ")
),
)
.with_suggestion("Qualify the ref as `<editor>/<tool>@<version>`."));
}
let editor = editors.pop_first().unwrap().to_string();
let v = resolve_version(&candidates)?;
Ok((editor, tool_part.to_string(), v))
}
}
fn merge_process(
default: &ProcessV2,
over: Option<&ProcessOverrideV2>,
) -> Result<serde_json::Value, OlError> {
use serde_json::json;
let command: Vec<String> = if let Some(o) = over {
if !o.command.is_empty() {
o.command.iter().map(|c| c.to_string()).collect()
} else if !o.command_args.is_empty() {
let mut base: Vec<String> = default.command.iter().map(|c| c.to_string()).collect();
base.extend(o.command_args.iter().cloned());
base
} else {
default.command.iter().map(|c| c.to_string()).collect()
}
} else {
default.command.iter().map(|c| c.to_string()).collect()
};
let cwd = over
.and_then(|o| o.cwd.clone())
.or_else(|| default.cwd.clone());
let mut env: std::collections::HashMap<String, String> = default.env.clone();
if let Some(o) = over {
for (k, v) in &o.env {
env.insert(k.clone(), v.clone());
}
}
let hc_value = match over.and_then(|o| o.health_check.as_ref()) {
Some(ovr_hc) => override_health_check_to_json(ovr_hc),
None => default_health_check_to_json(&default.health_check),
};
let restart_policy = match (
over.and_then(|o| o.restart.as_ref()),
default.restart.as_ref(),
) {
(Some(o), _) => Some((o.max_restarts, o.window_seconds)),
(None, Some(d)) => Some((d.max_restarts, d.window_seconds)),
_ => None,
};
let mut process = serde_json::Map::new();
process.insert("command".into(), json!(command.iter().collect::<Vec<_>>()));
if let Some(c) = cwd {
process.insert("cwd".into(), json!(c));
}
if !env.is_empty() {
process.insert(
"env".into(),
serde_json::to_value(&env).unwrap_or_else(|_| json!({})),
);
}
if let Some(v) = default.start_timeout_ms {
process.insert("start_timeout_ms".into(), json!(v));
}
if let Some(v) = default.kill_timeout_ms {
process.insert("kill_timeout_ms".into(), json!(v));
}
process.insert("health_check".into(), hc_value);
if let Some((max_restarts, window_seconds)) = restart_policy {
let mut rp = serde_json::Map::new();
if let Some(n) = max_restarts {
rp.insert("max_restarts".into(), json!(n.get()));
}
if let Some(n) = window_seconds {
rp.insert("window_ms".into(), json!(n.get() * 1000));
}
if !rp.is_empty() {
process.insert("restart_policy".into(), serde_json::Value::Object(rp));
}
}
process.insert("restart".into(), json!("always"));
Ok(serde_json::Value::Object(process))
}
fn synthesize_v1_binding(
v2: &BindingV2,
tool_slug: &str,
process_json: &serde_json::Value,
) -> serde_json::Value {
let mut val = serde_json::to_value(v2).unwrap_or(serde_json::Value::Null);
if let Some(map) = val.as_object_mut() {
map.remove("process_override");
map.insert(
"tool".into(),
serde_json::Value::String(tool_slug.to_string()),
);
map.insert("process".into(), process_json.clone());
}
val
}
fn synthesize_v1_tool(item: &ToolItemV2) -> serde_json::Value {
let mut val = serde_json::to_value(item).unwrap_or(serde_json::Value::Null);
if let Some(map) = val.as_object_mut() {
map.remove("process");
}
val
}
fn default_health_check_to_json(hc: &HealthCheckV2) -> serde_json::Value {
use serde_json::json;
let mut http = serde_json::Map::new();
http.insert("port".into(), json!(hc.http.port.get()));
if let Some(path) = hc.http.path.as_ref() {
http.insert("path".into(), json!(path.to_string()));
}
if let Some(v) = hc.http.startup_period_ms {
http.insert("startup_period_ms".into(), json!(v));
}
if let Some(v) = hc.http.startup_timeout_ms {
http.insert("startup_timeout_ms".into(), json!(v));
}
if let Some(v) = hc.http.liveness_period_ms {
http.insert("liveness_period_ms".into(), json!(v));
}
if let Some(v) = hc.http.liveness_timeout_ms {
http.insert("liveness_timeout_ms".into(), json!(v));
}
if let Some(v) = hc.http.liveness_failure_threshold {
http.insert("liveness_failure_threshold".into(), json!(v.get()));
}
json!({ "http": http })
}
fn override_health_check_to_json(hc: &ProcessOverrideV2HealthCheck) -> serde_json::Value {
use serde_json::json;
let mut http = serde_json::Map::new();
http.insert("port".into(), json!(hc.http.port.get()));
if let Some(path) = hc.http.path.as_ref() {
http.insert("path".into(), json!(path.to_string()));
}
if let Some(v) = hc.http.startup_period_ms {
http.insert("startup_period_ms".into(), json!(v));
}
if let Some(v) = hc.http.startup_timeout_ms {
http.insert("startup_timeout_ms".into(), json!(v));
}
if let Some(v) = hc.http.liveness_period_ms {
http.insert("liveness_period_ms".into(), json!(v));
}
if let Some(v) = hc.http.liveness_timeout_ms {
http.insert("liveness_timeout_ms".into(), json!(v));
}
if let Some(v) = hc.http.liveness_failure_threshold {
http.insert("liveness_failure_threshold".into(), json!(v.get()));
}
json!({ "http": http })
}
fn read_capped(path: &Path) -> Result<Vec<u8>, OlError> {
let bytes = std::fs::read(path).map_err(|e| {
OlError::new(
OL_4273_MANIFEST_UNREADABLE,
format!("cannot read `{}`: {e}", path.display()),
)
})?;
if bytes.len() > MAX_MANIFEST_BYTES {
return Err(OlError::new(
OL_4273_MANIFEST_UNREADABLE,
format!(
"`{}` is {} bytes (cap {} KB)",
path.display(),
bytes.len(),
MAX_MANIFEST_BYTES / 1024
),
));
}
Ok(bytes)
}
fn expand_glob(base: &Path, pattern: &str) -> Result<Vec<PathBuf>, OlError> {
let normalized: PathBuf = if Path::new(pattern).is_absolute() {
PathBuf::from(pattern)
} else {
let stripped = pattern.trim_start_matches("./").trim_start_matches(".\\");
base.join(stripped)
};
let parts: Vec<String> = normalized
.iter()
.map(|s| s.to_string_lossy().to_string())
.collect();
if parts.is_empty() {
return Ok(Vec::new());
}
let mut results: Vec<PathBuf> = vec![PathBuf::new()];
for part in parts {
let mut next: Vec<PathBuf> = Vec::new();
if part == "**" {
return Err(OlError::new(
OL_4325_TOOL_PATHS_ZERO_MATCH,
format!(
"recursive `**` in tool_paths glob `{pattern}` is not yet supported \
in v2; use single-segment `*` only"
),
));
}
let has_wildcard = part.contains('*') || part.contains('?');
for prefix in results.drain(..) {
if has_wildcard {
let parent = if prefix.as_os_str().is_empty() {
PathBuf::from("/")
} else {
prefix.clone()
};
let dir_to_list = if prefix.as_os_str().is_empty() {
continue;
} else {
parent
};
let entries = match std::fs::read_dir(&dir_to_list) {
Ok(e) => e,
Err(_) => continue, };
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if glob_match(&part, &name) {
next.push(prefix.join(&name));
}
}
} else {
next.push(prefix.join(&part));
}
}
results = next;
}
let mut filtered: Vec<PathBuf> = results.into_iter().filter(|p| p.is_file()).collect();
filtered.sort();
Ok(filtered)
}
fn glob_match(pattern: &str, candidate: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let c: Vec<char> = candidate.chars().collect();
glob_match_inner(&p, &c, 0, 0)
}
fn glob_match_inner(p: &[char], c: &[char], pi: usize, ci: usize) -> bool {
if pi == p.len() {
return ci == c.len();
}
match p[pi] {
'*' => {
for skip in ci..=c.len() {
if glob_match_inner(p, c, pi + 1, skip) {
return true;
}
}
false
}
'?' => {
if ci < c.len() {
glob_match_inner(p, c, pi + 1, ci + 1)
} else {
false
}
}
ch => {
if ci < c.len() && c[ci] == ch {
glob_match_inner(p, c, pi + 1, ci + 1)
} else {
false
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_match_basics() {
assert!(glob_match("*.yaml", "openlatch.yaml"));
assert!(glob_match("openlatch-*.yaml", "openlatch-tool.yaml"));
assert!(!glob_match("*.yml", "openlatch.yaml"));
assert!(glob_match("*", "anything"));
assert!(glob_match("foo?bar", "fooxbar"));
assert!(!glob_match("foo?bar", "fooxxbar"));
}
#[test]
fn peek_kind_v2_provider() {
let bytes = br#"
schema_version: 2
kind: Provider
providers: []
bindings: []
"#;
let (sv, kind) = peek_kind(bytes).unwrap();
assert_eq!(sv, Some(2));
assert_eq!(kind.as_deref(), Some("Provider"));
}
#[test]
fn peek_kind_v1_legacy() {
let bytes = br#"
schema_version: 1
editor:
slug: x
display_name: X
"#;
let (sv, kind) = peek_kind(bytes).unwrap();
assert_eq!(sv, Some(1));
assert_eq!(kind, None);
}
#[test]
fn resolve_ref_qualified() {
let mut tools: BTreeMap<(String, String, String), ResolvedTool> = BTreeMap::new();
let key = (
"openlatch".to_string(),
"coinflip-tool".to_string(),
"0.1.0".to_string(),
);
tools.insert(
key.clone(),
ResolvedTool {
manifest_path: PathBuf::from("/dev/null"),
editor_slug: "openlatch".to_string(),
editor: serde_json::from_value(serde_json::json!({
"slug": "openlatch",
"display_name": "OpenLatch"
}))
.expect("test editor fixture must deserialize"),
tool: serde_json::from_value(serde_json::json!({
"slug": "coinflip-tool",
"version": "0.1.0",
"license": "apache-2.0",
"description": "x",
"hooks_supported": ["pre_tool_use"],
"agents_supported": ["claude-code"],
"capabilities": [{
"threat_category": "pii_outbound",
"execution_mode": "sync",
"declared_latency_p95_ms": 30,
"needs_raw_payload": false
}],
"process": {
"command": ["echo", "hi"],
"health_check": { "http": { "port": 8081 } }
}
}))
.expect("test fixture must deserialize"),
},
);
let r = resolve_ref("openlatch/coinflip-tool@0.1.0", &tools).unwrap();
assert_eq!(
r,
("openlatch".into(), "coinflip-tool".into(), "0.1.0".into())
);
}
#[test]
fn resolve_ref_unresolved_errors() {
let tools: BTreeMap<(String, String, String), ResolvedTool> = BTreeMap::new();
let err = resolve_ref("openlatch/coinflip-tool@0.1.0", &tools).unwrap_err();
assert_eq!(err.code.code, "OL-4323");
}
#[test]
fn resolve_ref_malformed_errors() {
let tools: BTreeMap<(String, String, String), ResolvedTool> = BTreeMap::new();
let err = resolve_ref("no-version-here", &tools).unwrap_err();
assert_eq!(err.code.code, "OL-4323");
}
}