feat: add scoped reference formatters for the TUI
Follow Text View reference notation with component quoting and complete JSON/control/bidi safety escapes. Keep protocol reference pairs and operation names separate from display labels; screen integration remains T-23. Validated: cargo fmt --all --check; cargo check -p wip-tui --all-targets; cargo test -p wip-tui -p wip-protocol -p wip-client (99 tests incl. doctests); nix develop --command cargo clippy -p wip-tui --all-targets -- -D warnings; RUSTDOCFLAGS=-Dwarnings cargo doc -p wip-tui --no-deps; git diff --check.
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
use wip_protocol::{InterfaceReference, InterfaceTarget};
|
||||
use wip_tui::reference::{format_interface_reference, format_qualified_operation};
|
||||
|
||||
fn reference(scope: &str, name: &str) -> InterfaceReference {
|
||||
InterfaceReference {
|
||||
scope: scope.into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn specification_reference_examples() {
|
||||
for (scope, name, operation, expected) in [
|
||||
("/github", "issue.read", "read", "/github::issue.read#read"),
|
||||
("/", "issue.read", "read", "/::issue.read#read"),
|
||||
(
|
||||
"/github::archive",
|
||||
"issue.read",
|
||||
"read",
|
||||
"\"/github::archive\"::issue.read#read",
|
||||
),
|
||||
(
|
||||
"/github",
|
||||
"issue#read",
|
||||
"read",
|
||||
"/github::\"issue#read\"#read",
|
||||
),
|
||||
(
|
||||
"/github",
|
||||
"issue.read",
|
||||
"read#raw",
|
||||
"/github::issue.read#\"read#raw\"",
|
||||
),
|
||||
(
|
||||
"/開発",
|
||||
"課題.read",
|
||||
"read",
|
||||
"\"/開発\"::\"課題.read\"#read",
|
||||
),
|
||||
] {
|
||||
let input = reference(scope, name);
|
||||
input.validate().unwrap();
|
||||
let before = input.clone();
|
||||
assert_eq!(
|
||||
format_qualified_operation(&input, operation).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(input, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_self_and_same_name_different_scope_stay_distinct() {
|
||||
let root = reference("/", "read");
|
||||
let parent = reference("/a", "read");
|
||||
let self_scope = reference("/a/b", "read");
|
||||
let labels: Vec<_> = [&root, &parent, &self_scope]
|
||||
.into_iter()
|
||||
.map(|input| {
|
||||
input.validate_for_path("/a/b").unwrap();
|
||||
format_interface_reference(input).unwrap()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(labels, ["/::read", "/a::read", "/a/b::read"]);
|
||||
assert_eq!(
|
||||
format_qualified_operation(&self_scope, "read").unwrap(),
|
||||
"/a/b::read#read"
|
||||
);
|
||||
assert_ne!(root, self_scope);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_not_declaration_identifiers_or_signatures() {
|
||||
// Reference components follow their own ASCII rules, not identifier/keyword
|
||||
// backtick quoting or the outer JSON string quoting used by full signatures.
|
||||
let input = reference("/a-b/c_d.1", "1-name.entry");
|
||||
assert_eq!(
|
||||
format_interface_reference(&input).unwrap(),
|
||||
"/a-b/c_d.1::1-name.entry"
|
||||
);
|
||||
assert_eq!(
|
||||
format_qualified_operation(&input, "operation").unwrap(),
|
||||
"/a-b/c_d.1::1-name.entry#operation"
|
||||
);
|
||||
assert_eq!(
|
||||
format_qualified_operation(&input, "a/b").unwrap(),
|
||||
"/a-b/c_d.1::1-name.entry#\"a/b\""
|
||||
);
|
||||
assert_eq!(
|
||||
format_qualified_operation(&input, "").unwrap(),
|
||||
"/a-b/c_d.1::1-name.entry#\"\""
|
||||
);
|
||||
assert_eq!(
|
||||
format_interface_reference(&reference("/", "name`tick")).unwrap(),
|
||||
"/::\"name`tick\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delimiters_quote_backslash_whitespace_and_newline_are_component_data() {
|
||||
let input = reference("/a::b#c\"\\\n", "x::y#z\"\\\n");
|
||||
let before = input.clone();
|
||||
assert_eq!(
|
||||
format_interface_reference(&input).unwrap(),
|
||||
"\"/a::b#c\\\"\\\\\\n\"::\"x::y#z\\\"\\\\\\n\""
|
||||
);
|
||||
assert_eq!(
|
||||
format_qualified_operation(&input, "read::raw# \"\\\n\t").unwrap(),
|
||||
"\"/a::b#c\\\"\\\\\\n\"::\"x::y#z\\\"\\\\\\n\"#\"read::raw# \\\"\\\\\\n\\t\""
|
||||
);
|
||||
assert_eq!(input, before);
|
||||
assert_eq!(
|
||||
format_interface_reference(&reference("/space here", " name ")).unwrap(),
|
||||
"\"/space here\"::\" name \""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_required_safety_escape_in_each_component() {
|
||||
let controls = (0..=0x1f)
|
||||
.chain(0x7f..=0x9f)
|
||||
.chain([0x061c, 0x200e, 0x200f, 0x2028, 0x2029])
|
||||
.chain(0x202a..=0x202e)
|
||||
.chain(0x2066..=0x2069);
|
||||
for code in controls {
|
||||
let ch = char::from_u32(code).unwrap();
|
||||
let escape = match code {
|
||||
8 => "\\b".into(),
|
||||
9 => "\\t".into(),
|
||||
10 => "\\n".into(),
|
||||
12 => "\\f".into(),
|
||||
13 => "\\r".into(),
|
||||
_ => format!("\\u{code:04x}"),
|
||||
};
|
||||
let input = reference(&format!("/a{ch}z"), &format!("n{ch}z"));
|
||||
let operation = format!("o{ch}z");
|
||||
let before = (input.clone(), operation.clone());
|
||||
let label = format_qualified_operation(&input, &operation).unwrap();
|
||||
assert_eq!(
|
||||
label,
|
||||
format!("\"/a{escape}z\"::\"n{escape}z\"#\"o{escape}z\""),
|
||||
"U+{code:04X}"
|
||||
);
|
||||
assert!(!label.contains(ch), "raw U+{code:04X} leaked");
|
||||
assert_eq!((input, operation), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_remains_exact_no_normalization_case_folding_or_url_encoding() {
|
||||
for name in ["é", "e\u{301}", "課題", "λ😀", "%23"] {
|
||||
let input = reference(&format!("/{name}"), name);
|
||||
assert_eq!(
|
||||
format_qualified_operation(&input, name).unwrap(),
|
||||
format!("\"/{name}\"::\"{name}\"#\"{name}\"")
|
||||
);
|
||||
}
|
||||
assert_ne!(
|
||||
format_interface_reference(&reference("/", "é")).unwrap(),
|
||||
format_interface_reference(&reference("/", "e\u{301}")).unwrap()
|
||||
);
|
||||
assert_ne!(
|
||||
format_interface_reference(&reference("/", "Read")).unwrap(),
|
||||
format_interface_reference(&reference("/", "read")).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_shape_instead_of_inferring_scope() {
|
||||
for scope in [
|
||||
"",
|
||||
"github",
|
||||
"../github",
|
||||
"/a/../b",
|
||||
"/a/./b",
|
||||
"/a//b",
|
||||
"/a/",
|
||||
] {
|
||||
let input = reference(scope, "read");
|
||||
assert_eq!(
|
||||
format_interface_reference(&input).unwrap_err(),
|
||||
input.validate().unwrap_err()
|
||||
);
|
||||
assert!(format_qualified_operation(&input, "read").is_err());
|
||||
}
|
||||
assert!(format_interface_reference(&reference("/", "")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_metadata_and_call_identity_stay_separate_from_display() {
|
||||
let mut target = InterfaceTarget {
|
||||
reference: reference("/a", "read#raw"),
|
||||
scope_ref: Some("secret-scope-ref".into()),
|
||||
validator: Some(vec![27, 255]),
|
||||
};
|
||||
let before = target.clone();
|
||||
let label = format_qualified_operation(&target.reference, "run").unwrap();
|
||||
assert_eq!(label, "/a::\"read#raw\"#run");
|
||||
assert_eq!(target, before);
|
||||
target.scope_ref = None;
|
||||
target.validator = None;
|
||||
assert_eq!(
|
||||
format_qualified_operation(&target.reference, "run").unwrap(),
|
||||
label
|
||||
);
|
||||
// Selection/call keys are still exact protocol pairs; labels are disposable.
|
||||
let mut selected = std::collections::BTreeMap::new();
|
||||
selected.insert(target.reference.clone(), "run");
|
||||
assert_eq!(selected.get(&before.reference), Some(&"run"));
|
||||
assert!(!selected.contains_key(&reference("/", "read#raw")));
|
||||
}
|
||||
Reference in New Issue
Block a user