Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
192
crates/common/xai-tool-protocol/tests/identifier_validation.rs
Normal file
192
crates/common/xai-tool-protocol/tests/identifier_validation.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
//! Validation rules for every identifier newtype, plus the synthetic
|
||||
//! `ServerId` helper invariants.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use xai_tool_protocol::{
|
||||
ConnectionId, IdError, RequestId, ServerId, SessionId, ToolCallId, ToolId, UserId,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn tool_id_accepts_bare_and_namespaced_names() {
|
||||
let bare = ToolId::new("read_file").unwrap();
|
||||
assert_eq!(bare.as_str(), "read_file");
|
||||
|
||||
let namespaced = ToolId::new("GrokBuild:read_file").unwrap();
|
||||
assert_eq!(namespaced.as_str(), "GrokBuild:read_file");
|
||||
|
||||
assert_eq!(
|
||||
ToolId::from_str("github:list_repos").unwrap().as_str(),
|
||||
"github:list_repos"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_id_rejects_empty() {
|
||||
assert_eq!(ToolId::new("").unwrap_err(), IdError::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_id_rejects_more_than_one_separator() {
|
||||
let err = ToolId::new("foo:bar:baz").unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
IdError::InvalidFormat {
|
||||
value: "foo:bar:baz".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_id_rejects_disallowed_characters() {
|
||||
for bad in [
|
||||
"foo bar",
|
||||
"foo!bar",
|
||||
"foo/bar",
|
||||
"foo.bar",
|
||||
"foo:bar baz",
|
||||
" foo",
|
||||
"foo ",
|
||||
"foo\tbar",
|
||||
"foo\u{00e9}bar",
|
||||
"f\u{00f6}\u{00f6}bar",
|
||||
] {
|
||||
let err = ToolId::new(bad).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, IdError::InvalidFormat { ref value } if value == bad),
|
||||
"expected InvalidFormat for {bad:?}, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_id_accepts_digits_only_and_other_boundary_inputs() {
|
||||
assert_eq!(ToolId::new("123").unwrap().as_str(), "123");
|
||||
assert_eq!(ToolId::new("v2:42").unwrap().as_str(), "v2:42");
|
||||
assert_eq!(ToolId::new("a").unwrap().as_str(), "a");
|
||||
assert_eq!(ToolId::new("a:b").unwrap().as_str(), "a:b");
|
||||
assert_eq!(ToolId::new("-_-").unwrap().as_str(), "-_-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_id_rejects_empty_segments_around_separator() {
|
||||
for bad in [":foo", "foo:", ":"] {
|
||||
let err = ToolId::new(bad).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, IdError::InvalidFormat { ref value } if value == bad),
|
||||
"expected InvalidFormat for {bad:?}, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_id_try_from_string_works() {
|
||||
let id: ToolId = "GrokBuild:read_file".to_owned().try_into().unwrap();
|
||||
assert_eq!(id.as_str(), "GrokBuild:read_file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_id_accepts_arbitrary_non_empty_strings() {
|
||||
for ok in ["my-uuid-v7", "srv_42", "x", "abc.def"] {
|
||||
let s = ServerId::new(ok).unwrap();
|
||||
assert_eq!(s.as_str(), ok);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_id_rejects_empty() {
|
||||
assert_eq!(ServerId::new("").unwrap_err(), IdError::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_id_rejects_reserved_auto_prefix() {
|
||||
for bad in ["auto:my-server", "auto:", "auto:tool:read_file"] {
|
||||
let err = ServerId::new(bad).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, IdError::ReservedPrefix { ref value } if value == bad),
|
||||
"expected ReservedPrefix for {bad:?}, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_id_synthesis_starts_with_auto_prefix() {
|
||||
let conn = ConnectionId::new("conn-abc").unwrap();
|
||||
let bare = ToolId::new("read_file").unwrap();
|
||||
let synth = ServerId::synthesize_for_tool(&conn, &bare);
|
||||
assert_eq!(synth.as_str(), "auto:tool:read_file");
|
||||
|
||||
let namespaced = ToolId::new("GrokBuild:read_file").unwrap();
|
||||
let synth_ns = ServerId::synthesize_for_tool(&conn, &namespaced);
|
||||
assert_eq!(synth_ns.as_str(), "auto:tool:GrokBuild:read_file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_id_synthesis_is_deterministic() {
|
||||
let conn = ConnectionId::new("conn-abc").unwrap();
|
||||
let tool = ToolId::new("GrokBuild:read_file").unwrap();
|
||||
let a = ServerId::synthesize_for_tool(&conn, &tool);
|
||||
let b = ServerId::synthesize_for_tool(&conn, &tool);
|
||||
assert_eq!(
|
||||
a, b,
|
||||
"synthesis must be a pure function of (connection, tool)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_id_synthesis_bypasses_reserved_prefix_check() {
|
||||
// The synthesised id starts with `auto:`; the reserved-prefix rule
|
||||
// only applies to client-supplied values via `ServerId::new`.
|
||||
let conn = ConnectionId::new("conn-abc").unwrap();
|
||||
let tool = ToolId::new("read_file").unwrap();
|
||||
let synth = ServerId::synthesize_for_tool(&conn, &tool);
|
||||
assert!(synth.as_str().starts_with("auto:"));
|
||||
|
||||
let err = ServerId::new(synth.as_str()).unwrap_err();
|
||||
assert!(matches!(err, IdError::ReservedPrefix { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_ids_reject_empty() {
|
||||
assert_eq!(SessionId::new("").unwrap_err(), IdError::Empty);
|
||||
assert_eq!(UserId::new("").unwrap_err(), IdError::Empty);
|
||||
assert_eq!(ConnectionId::new("").unwrap_err(), IdError::Empty);
|
||||
assert_eq!(RequestId::new("").unwrap_err(), IdError::Empty);
|
||||
assert_eq!(ToolCallId::new("").unwrap_err(), IdError::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_ids_accept_arbitrary_non_empty_strings() {
|
||||
assert_eq!(
|
||||
SessionId::new("anything goes").unwrap().as_str(),
|
||||
"anything goes"
|
||||
);
|
||||
assert_eq!(
|
||||
UserId::new("alice@example.com").unwrap().as_str(),
|
||||
"alice@example.com"
|
||||
);
|
||||
assert_eq!(RequestId::new("req-9c4f").unwrap().as_str(), "req-9c4f");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_id_uuid_v7_helper_is_unique_and_valid_uuid() {
|
||||
let a = ToolCallId::new_v7();
|
||||
let b = ToolCallId::new_v7();
|
||||
assert_ne!(a, b, "two consecutive v7 ids must differ");
|
||||
for id in [&a, &b] {
|
||||
let parsed = uuid::Uuid::parse_str(id.as_str()).expect("parse uuid");
|
||||
assert_eq!(
|
||||
parsed.get_version_num(),
|
||||
7,
|
||||
"expected UUID v7, got {parsed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_id_display_matches_inner_string() {
|
||||
let s = SessionId::new("sess_abc").unwrap();
|
||||
assert_eq!(format!("{s}"), "sess_abc");
|
||||
let t = ToolId::new("github:list_repos").unwrap();
|
||||
assert_eq!(format!("{t}"), "github:list_repos");
|
||||
}
|
||||
265
crates/common/xai-tool-protocol/tests/jsonrpc_envelope.rs
Normal file
265
crates/common/xai-tool-protocol/tests/jsonrpc_envelope.rs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
//! Envelope-shape tests for the JSON-RPC 2.0 wrappers.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use xai_tool_protocol::{
|
||||
FrameSeq, JsonRpcError, JsonRpcId, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse,
|
||||
JsonRpcVersion, RequestId, ResponseOutcome, SessionId,
|
||||
};
|
||||
|
||||
fn session() -> SessionId {
|
||||
SessionId::new("sess_abc").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_with_no_session_id_omits_envelope_field() {
|
||||
let req: JsonRpcRequest<Value> = JsonRpcRequest {
|
||||
jsonrpc: JsonRpcVersion,
|
||||
id: JsonRpcId::new_string("req-1"),
|
||||
session_id: None,
|
||||
method: "tool.call".to_owned(),
|
||||
params: json!({}),
|
||||
};
|
||||
let v = serde_json::to_value(&req).unwrap();
|
||||
let obj = v.as_object().unwrap();
|
||||
assert!(
|
||||
!obj.contains_key("session_id"),
|
||||
"session_id=None must be omitted: {v}"
|
||||
);
|
||||
assert_eq!(v["jsonrpc"], json!("2.0"));
|
||||
assert_eq!(v["id"], json!("req-1"));
|
||||
assert_eq!(v["method"], json!("tool.call"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_with_session_id_includes_envelope_field() {
|
||||
let req: JsonRpcRequest<Value> = JsonRpcRequest {
|
||||
jsonrpc: JsonRpcVersion,
|
||||
id: JsonRpcId::new_string("req-2"),
|
||||
session_id: Some(session()),
|
||||
method: "tool.call".to_owned(),
|
||||
params: json!({"x": 1}),
|
||||
};
|
||||
let v = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(v["session_id"], json!("sess_abc"));
|
||||
assert_eq!(v["params"]["x"], json!(1));
|
||||
let parsed: JsonRpcRequest<Value> = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(parsed.id, JsonRpcId::new_string("req-2"));
|
||||
assert_eq!(
|
||||
parsed.session_id.as_ref().map(|s| s.as_str()),
|
||||
Some("sess_abc")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_with_no_seq_omits_envelope_field() {
|
||||
let n: JsonRpcNotification<Value> = JsonRpcNotification {
|
||||
jsonrpc: JsonRpcVersion,
|
||||
session_id: None,
|
||||
seq: None,
|
||||
method: "tool.notification".to_owned(),
|
||||
params: json!({}),
|
||||
};
|
||||
let v = serde_json::to_value(&n).unwrap();
|
||||
let obj = v.as_object().unwrap();
|
||||
assert!(!obj.contains_key("seq"));
|
||||
assert!(!obj.contains_key("session_id"));
|
||||
assert!(!obj.contains_key("id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_with_seq_includes_envelope_field() {
|
||||
let n: JsonRpcNotification<Value> = JsonRpcNotification {
|
||||
jsonrpc: JsonRpcVersion,
|
||||
session_id: Some(session()),
|
||||
seq: Some(FrameSeq::new(42)),
|
||||
method: "tool.notification".to_owned(),
|
||||
params: json!({}),
|
||||
};
|
||||
let v = serde_json::to_value(&n).unwrap();
|
||||
assert_eq!(v["seq"], json!(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_ok_serialises_with_result_only() {
|
||||
let resp: JsonRpcResponse<Value> =
|
||||
JsonRpcResponse::ok(JsonRpcId::new_string("r"), json!({"y": 2}));
|
||||
let v = serde_json::to_value(&resp).unwrap();
|
||||
let obj = v.as_object().unwrap();
|
||||
assert_eq!(v["jsonrpc"], json!("2.0"));
|
||||
assert_eq!(v["id"], json!("r"));
|
||||
assert_eq!(v["result"], json!({"y": 2}));
|
||||
assert!(!obj.contains_key("error"), "ok must omit `error`: {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_err_serialises_with_error_only() {
|
||||
let resp: JsonRpcResponse<Value> = JsonRpcResponse::err(
|
||||
JsonRpcId::new_string("r"),
|
||||
JsonRpcError {
|
||||
code: -32011,
|
||||
message: "tool not found".to_owned(),
|
||||
data: Some(json!({"code": "tool_not_found"})),
|
||||
},
|
||||
);
|
||||
let v = serde_json::to_value(&resp).unwrap();
|
||||
let obj = v.as_object().unwrap();
|
||||
assert_eq!(v["error"]["code"], json!(-32011));
|
||||
assert_eq!(v["error"]["data"]["code"], json!("tool_not_found"));
|
||||
assert!(!obj.contains_key("result"), "err must omit `result`: {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_round_trips_with_session_envelope() {
|
||||
let resp: JsonRpcResponse<Value> =
|
||||
JsonRpcResponse::ok(JsonRpcId::Number(7), json!({})).with_session(session());
|
||||
let v = serde_json::to_value(&resp).unwrap();
|
||||
assert_eq!(v["session_id"], json!("sess_abc"));
|
||||
assert_eq!(v["id"], json!(7));
|
||||
let parsed: JsonRpcResponse<Value> = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(parsed.id, JsonRpcId::Number(7));
|
||||
match parsed.outcome {
|
||||
ResponseOutcome::Result(_) => {}
|
||||
ResponseOutcome::Error(e) => panic!("expected Result, got Error({e:?})"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_with_both_result_and_error_fails_to_deserialize() {
|
||||
let bad = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": "r",
|
||||
"result": {"x": 1},
|
||||
"error": {"code": -32000, "message": "no"},
|
||||
});
|
||||
let err = serde_json::from_value::<JsonRpcResponse<Value>>(bad).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("XOR"),
|
||||
"expected XOR-violation message, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_with_neither_result_nor_error_fails_to_deserialize() {
|
||||
let bad = json!({"jsonrpc": "2.0", "id": "r"});
|
||||
let err = serde_json::from_value::<JsonRpcResponse<Value>>(bad).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("`result` or `error`"),
|
||||
"expected exactly-one message, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonrpc_error_round_trips_with_and_without_data() {
|
||||
let e_no_data = JsonRpcError {
|
||||
code: -32603,
|
||||
message: "internal".to_owned(),
|
||||
data: None,
|
||||
};
|
||||
let v = serde_json::to_value(&e_no_data).unwrap();
|
||||
assert!(
|
||||
!v.as_object().unwrap().contains_key("data"),
|
||||
"data=None must be omitted: {v}"
|
||||
);
|
||||
let back: JsonRpcError = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, e_no_data);
|
||||
|
||||
let e_with_data = JsonRpcError {
|
||||
code: -32011,
|
||||
message: "tool not found".to_owned(),
|
||||
data: Some(json!({"code": "tool_not_found", "tool_id": "echo"})),
|
||||
};
|
||||
let v = serde_json::to_value(&e_with_data).unwrap();
|
||||
assert_eq!(v["data"]["tool_id"], json!("echo"));
|
||||
let back: JsonRpcError = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, e_with_data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonrpc_id_accepts_string_and_number_on_request() {
|
||||
let v_str = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": "req-9c4f",
|
||||
"method": "tool.call",
|
||||
"params": {},
|
||||
});
|
||||
let req: JsonRpcRequest<Value> = serde_json::from_value(v_str).unwrap();
|
||||
assert_eq!(req.id, JsonRpcId::new_string("req-9c4f"));
|
||||
|
||||
let v_num = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 99,
|
||||
"method": "tool.call",
|
||||
"params": {},
|
||||
});
|
||||
let req: JsonRpcRequest<Value> = serde_json::from_value(v_num).unwrap();
|
||||
assert_eq!(req.id, JsonRpcId::Number(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonrpc_id_round_trips_to_request_id_correlator() {
|
||||
let original = RequestId::new("req-42").unwrap();
|
||||
let envelope_id = JsonRpcId::from_request_id(&original);
|
||||
assert_eq!(envelope_id.as_request_id().unwrap(), original);
|
||||
|
||||
// Numeric ids are stringified.
|
||||
let nid = JsonRpcId::Number(7);
|
||||
assert_eq!(nid.as_request_id().unwrap().as_str(), "7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_call_envelope_serialises_to_expected_shape() {
|
||||
use xai_tool_protocol::{ToolCallId, ToolCallParams, ToolId};
|
||||
let req = JsonRpcRequest {
|
||||
jsonrpc: JsonRpcVersion,
|
||||
id: JsonRpcId::new_string("req-9c4f"),
|
||||
session_id: Some(session()),
|
||||
method: "tool.call".to_owned(),
|
||||
params: ToolCallParams {
|
||||
tool_call_id: ToolCallId::new("call_xyz").unwrap(),
|
||||
tool_id: ToolId::new("GrokBuild:read_file").unwrap(),
|
||||
arguments: json!({"path": "/etc/hosts"}),
|
||||
deadline_ms: None,
|
||||
behavior_version: None,
|
||||
cwd: None,
|
||||
trace_context: None,
|
||||
},
|
||||
};
|
||||
let v = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(v["jsonrpc"], json!("2.0"));
|
||||
assert_eq!(v["id"], json!("req-9c4f"));
|
||||
assert_eq!(v["session_id"], json!("sess_abc"));
|
||||
assert_eq!(v["method"], json!("tool.call"));
|
||||
assert_eq!(v["params"]["tool_id"], json!("GrokBuild:read_file"));
|
||||
assert_eq!(v["params"]["tool_call_id"], json!("call_xyz"));
|
||||
}
|
||||
|
||||
/// The envelope-level `session_id` and an inner `params.session_id` (e.g.
|
||||
/// on `ToolsListParams`) are independent keys in the wire JSON tree.
|
||||
/// This test pins that invariant so a refactor that accidentally
|
||||
/// collapses the two layers (e.g. via `#[serde(flatten)]`) fails loudly.
|
||||
#[test]
|
||||
fn envelope_session_id_and_inner_params_session_id_are_distinct_layers() {
|
||||
use xai_tool_protocol::{ToolDefinitionMode, ToolsListParams};
|
||||
let req = JsonRpcRequest {
|
||||
jsonrpc: JsonRpcVersion,
|
||||
id: JsonRpcId::new_string("req-mix"),
|
||||
session_id: Some(session()),
|
||||
method: "tools.list".to_owned(),
|
||||
params: ToolsListParams {
|
||||
session_id: session(),
|
||||
mode: ToolDefinitionMode::Full,
|
||||
},
|
||||
};
|
||||
let v = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(v["session_id"], json!("sess_abc"));
|
||||
assert_eq!(v["params"]["session_id"], json!("sess_abc"));
|
||||
let top_keys: std::collections::BTreeSet<&str> =
|
||||
v.as_object().unwrap().keys().map(String::as_str).collect();
|
||||
assert_eq!(
|
||||
top_keys,
|
||||
["id", "jsonrpc", "method", "params", "session_id"]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
);
|
||||
}
|
||||
1680
crates/common/xai-tool-protocol/tests/serde_roundtrip.rs
Normal file
1680
crates/common/xai-tool-protocol/tests/serde_roundtrip.rs
Normal file
File diff suppressed because it is too large
Load diff
93
crates/common/xai-tool-protocol/tests/tool_id_derivation.rs
Normal file
93
crates/common/xai-tool-protocol/tests/tool_id_derivation.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
//! `ToolDescriptionWithSchema::derive_tool_id`: namespaced descriptions
|
||||
//! render as `"{ns}:{name}"`; bare names pass through; descriptions whose
|
||||
//! derived id fails [`ToolId`] validation return `Err`.
|
||||
|
||||
use xai_tool_protocol::{IdError, ToolDescriptionWithSchema, ToolId};
|
||||
use xai_tool_types::ToolDescription;
|
||||
|
||||
fn entry(name: &str, namespace: Option<&str>) -> ToolDescriptionWithSchema {
|
||||
let mut description = ToolDescription::new(name, "test description");
|
||||
if let Some(ns) = namespace {
|
||||
description = description.with_namespace(ns);
|
||||
}
|
||||
ToolDescriptionWithSchema {
|
||||
description,
|
||||
input_schema: None,
|
||||
capabilities: None,
|
||||
notification_schemas: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_name_derives_to_tool_id_without_namespace() {
|
||||
let derived = entry("read_file", None).derive_tool_id().unwrap();
|
||||
assert_eq!(derived, ToolId::new("read_file").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespaced_name_derives_to_namespaced_tool_id() {
|
||||
let derived = entry("read_file", Some("GrokBuild"))
|
||||
.derive_tool_id()
|
||||
.unwrap();
|
||||
assert_eq!(derived, ToolId::new("GrokBuild:read_file").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_name_propagates_id_validation_error() {
|
||||
let err = entry("foo bar", None).derive_tool_id().unwrap_err();
|
||||
assert!(
|
||||
matches!(err, IdError::InvalidFormat { .. }),
|
||||
"expected InvalidFormat, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_namespace_propagates_id_validation_error() {
|
||||
let err = entry("read_file", Some("bad ns"))
|
||||
.derive_tool_id()
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, IdError::InvalidFormat { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_name_yields_empty_id_error() {
|
||||
let err = entry("", None).derive_tool_id().unwrap_err();
|
||||
assert_eq!(err, IdError::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_derivations_in_a_batch_are_detectable() {
|
||||
let batch = [
|
||||
entry("read_file", Some("GrokBuild")),
|
||||
entry("write_file", Some("GrokBuild")),
|
||||
entry("read_file", Some("GrokBuild")),
|
||||
];
|
||||
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
let mut duplicates: Vec<(ToolId, Vec<usize>)> = Vec::new();
|
||||
for (i, e) in batch.iter().enumerate() {
|
||||
let id = e.derive_tool_id().unwrap();
|
||||
seen.entry(id).or_insert_with(Vec::new).push(i);
|
||||
}
|
||||
for (id, indices) in seen {
|
||||
if indices.len() > 1 {
|
||||
duplicates.push((id, indices));
|
||||
}
|
||||
}
|
||||
assert_eq!(duplicates.len(), 1, "exactly one duplicate id expected");
|
||||
let (id, indices) = &duplicates[0];
|
||||
assert_eq!(id, &ToolId::new("GrokBuild:read_file").unwrap());
|
||||
assert_eq!(indices, &vec![0, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derivation_does_not_collide_across_namespaces() {
|
||||
let a = entry("read_file", Some("GrokBuild"))
|
||||
.derive_tool_id()
|
||||
.unwrap();
|
||||
let b = entry("read_file", Some("github")).derive_tool_id().unwrap();
|
||||
let c = entry("read_file", None).derive_tool_id().unwrap();
|
||||
assert_ne!(a, b);
|
||||
assert_ne!(a, c);
|
||||
assert_ne!(b, c);
|
||||
}
|
||||
Loading…
Reference in a new issue