diff --git a/crates/wip-tui/README.md b/crates/wip-tui/README.md index 2629456..8a1cb7c 100644 --- a/crates/wip-tui/README.md +++ b/crates/wip-tui/README.md @@ -64,6 +64,38 @@ enter `contents` as RFC 4648 base64—for example, `dXBkYXRlZA==` for `updated`—then review the target Entry, interface reference, operation, and value on the confirmation screen before pressing Enter again. +## Scoped reference formatting + +`wip_tui::reference::{format_interface_reference, format_qualified_operation}` +provides labels for the existing UI from `InterfaceReference` and a separate +operation name, following `4.2-text-view.md` reference notation (`6086f3c`). + +```rust +use wip_protocol::InterfaceReference; +use wip_tui::reference::{format_interface_reference, format_qualified_operation}; + +let reference = InterfaceReference { scope: "/github".into(), name: "issue#read".into() }; +assert_eq!(format_interface_reference(&reference)?, "/github::\"issue#read\""); +assert_eq!(format_qualified_operation(&reference, "read#raw")?, + "/github::\"issue#read\"#\"read#raw\""); +# Ok::<(), wip_protocol::ValidationError>(()) +``` + +Both return `Result` and reject invalid reference shape, +including relative/omitted scope. Root is explicit `/`. Non-simple components use +JSON string quoting with additional DEL/C1, Unicode line/paragraph separator and +bidi-control escapes. Input values are borrowed and never normalized, case-folded, +URL-encoded or split on delimiters. Ref, validators and `scope_ref` do not enter +these labels. The output has no extra outer signature string quoting. + +Keep selection, cache and calls keyed by structured references and operation +names, never display text. No parser, scope inference, identity reconstruction, +Object/Interface signature renderer or new crate is introduced. Membership, +ancestry, authorization and freshness remain runtime concerns. Screen integration +is follow-up T-23; the current bounded screen/value/diagnostic rendering is +unchanged. At integration, apply existing UI layout bounds to display labels +without feeding shortened labels back into identity or calls. + ## Keyboard bindings All required actions are keyboard-accessible. diff --git a/crates/wip-tui/src/lib.rs b/crates/wip-tui/src/lib.rs index 5823ce2..800df1e 100644 --- a/crates/wip-tui/src/lib.rs +++ b/crates/wip-tui/src/lib.rs @@ -5,6 +5,7 @@ pub mod app; pub mod config; pub mod editor; +pub mod reference; pub mod render; pub mod runtime; pub mod terminal; diff --git a/crates/wip-tui/src/reference.rs b/crates/wip-tui/src/reference.rs new file mode 100644 index 0000000..9068d4e --- /dev/null +++ b/crates/wip-tui/src/reference.rs @@ -0,0 +1,107 @@ +//! Scoped Interface/Operation labels for the existing TUI. +//! +//! Follows the reference notation and safety escapes in WIP `4.2-text-view.md`. +//! These are display labels, not wire values, cache keys or call targets. Keep +//! [`InterfaceReference`] and operation names structured for selection and calls; +//! do not split or reparse labels. No Object/Interface signature renderer or +//! shorthand input parser is provided. Screen integration and bounded layout +//! remain separate from formatting. +//! +//! Scope is always explicit and canonical absolute. Adapters accepting omitted +//! scope must resolve it with an explicit context at their input boundary, never +//! default to root, infer from a target path or search ancestors. Membership, +//! ancestry and authorization are separate checks, not claims made by a label. + +use std::fmt::Write as _; +use wip_protocol::{InterfaceReference, ValidationError}; + +/// Formats a complete `scope::interface` label from structured values. +/// +/// Validates reference shape (including canonical absolute scope), then quotes +/// components as needed. There is no outer signature JSON string layer. The +/// exact input is preserved without normalization, case conversion or URL +/// encoding. Ref, validators and `scope_ref` are not inputs and cannot leak into +/// the label. This does not validate membership, freshness or authorization. +/// +/// ``` +/// use wip_protocol::InterfaceReference; +/// use wip_tui::reference::format_interface_reference; +/// let reference = InterfaceReference { +/// scope: "/github::archive".into(), name: "issue#read".into(), +/// }; +/// assert_eq!(format_interface_reference(&reference)?, +/// "\"/github::archive\"::\"issue#read\""); +/// # Ok::<(), wip_protocol::ValidationError>(()) +/// ``` +pub fn format_interface_reference( + reference: &InterfaceReference, +) -> Result { + reference.validate()?; + Ok(format!( + "{}::{}", + component(&reference.scope, true), + component(&reference.name, false) + )) +} + +/// Formats a complete `scope::interface#operation` display designation. +/// +/// Operation remains a separate exact Unicode name, not an input expression. +/// Delimiters inside it are quoted rather than split. This checks reference +/// shape, not operation existence, and adds no operation-name restrictions to +/// the Protocol. The output is plain text, without ANSI or Markdown; the UI +/// remains responsible for bounded screen layout, not identity reconstruction. +/// +/// ``` +/// use wip_protocol::InterfaceReference; +/// use wip_tui::reference::format_qualified_operation; +/// let reference = InterfaceReference { scope: "/".into(), name: "issue.read".into() }; +/// assert_eq!(format_qualified_operation(&reference, "read#raw")?, +/// "/::issue.read#\"read#raw\""); +/// # Ok::<(), wip_protocol::ValidationError>(()) +/// ``` +pub fn format_qualified_operation( + reference: &InterfaceReference, + operation: &str, +) -> Result { + let mut output = format_interface_reference(reference)?; + output.push('#'); + output.push_str(&component(operation, false)); + Ok(output) +} + +fn component(value: &str, scope: bool) -> String { + if !value.is_empty() + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b) || (scope && b == b'/')) + { + return value.to_owned(); + } + let mut output = String::from("\""); + for ch in value.chars() { + match ch { + '\\' => output.push_str("\\\\"), + '"' => output.push_str("\\\""), + '\u{08}' => output.push_str("\\b"), + '\u{0c}' => output.push_str("\\f"), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + '\u{00}'..='\u{1f}' + | '\u{7f}'..='\u{9f}' + | '\u{2028}' + | '\u{2029}' + | '\u{061c}' + | '\u{200e}'..='\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2066}'..='\u{2069}' => { + // String's fmt::Write implementation is infallible. + write!(output, "\\u{:04x}", ch as u32).expect("String write"); + } + _ => output.push(ch), + } + } + output.push('"'); + output +} diff --git a/crates/wip-tui/tests/references.rs b/crates/wip-tui/tests/references.rs new file mode 100644 index 0000000..e7003ff --- /dev/null +++ b/crates/wip-tui/tests/references.rs @@ -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"))); +}