1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
//! **The legacy `Question<A>` as an `InteractionDefinition`** (A2.2, #1828).
//!
//! One lossless mapping between the prompt model already in production and
//! the renderer-neutral model A2 defines. Nothing here is wired into a
//! production path: A2.2's job is to PROVE the new model can express the
//! old one; switching the terminal and web surfaces onto it is B0's slice,
//! with its own deletion gate. `no_production_path_uses_the_adapter_yet`
//! holds that line mechanically.
//!
//! # The shape
//!
//! A `Question<A>` is a prompt plus `Vec<Action>` — ONE question offering N
//! mutually-exclusive actions, rendered one per line
//! (`[a]llow once` / `[s]ession allow` / `[d]eny (default)`). A0's golden
//! joined them onto a single row until C0c (#1907) gave each its own. Under
//! A2.1's
//! model that is exactly ONE [`Control`] of kind
//! [`ControlKind::Choice`], with one [`ChoiceOption`] per action and the
//! action's meaning carried on the option.
//!
//! That the mapping falls out with no second id space and no second control
//! is the evidence for round 3's redesign, not a coincidence: the old model
//! had been describing one field with many options all along, and the
//! flat-controls shape it replaced could not have expressed this without
//! making `requirement` incoherent.
//!
//! # What "lossless" costs
//!
//! Everything a `Question` carries has a home:
//!
//! | `Question<A>` | `InteractionDefinition` |
//! |---|---|
//! | `markdown` | `markdown` |
//! | `note` | `note` |
//! | `actions[i].value` | the option's `id` (the action's WIRE name) |
//! | `actions[i].key` | the option's `key` |
//! | `actions[i].label` | the option's `label` |
//! | `actions[i].aliases` | the option's `aliases` |
//!
//! An option id is the action's wire name (`allow_once`), not its hotkey:
//! the wire name is the stable identity, the key is a presentation affordance
//! that differs per surface. Both survive because both are carried.
use newt_interaction::{
ChoiceOption, Control, ControlId, ControlKind, InteractionDefinition, InteractionKind,
OptionId, ProtocolError, Requirement, SemanticRole,
};
use crate::tty::Question;
use crate::PermissionAction;
/// The single control every adapted question carries.
///
/// A stable, reserved name: the definition has exactly one field, and B0
/// needs to address it without guessing.
pub const DECISION_CONTROL: &str = "decision";
/// What an action MEANS, for the option that carries it.
///
/// Derived from the action rather than declared, because the legacy type
/// has no role field — the meaning was encoded in the variant all along.
///
/// Public since B0a (#1841): `newt-tui` builds the definition directly and
/// needs the same mapping. Exposing it keeps ONE action→role table; a
/// second copy in the policy layer is the duplication this epic deletes.
#[must_use]
pub fn role_of(action: PermissionAction) -> SemanticRole {
match action {
PermissionAction::AllowOnce
| PermissionAction::AllowSession
| PermissionAction::AllowPermanent => SemanticRole::Allow,
PermissionAction::Deny | PermissionAction::DenyAlways | PermissionAction::DenyPermanent => {
SemanticRole::Deny
}
PermissionAction::Back => SemanticRole::Cancel,
PermissionAction::Exit => SemanticRole::Exit,
}
}
/// Recover the action a wire name denotes.
///
/// Public since B0b-1 (#1842): the interaction gate resolves an accepted
/// option back to the action it authorizes, and a second copy of this
/// table would be the duplication this epic deletes.
#[must_use]
pub fn action_for_option(wire: &str) -> Option<PermissionAction> {
[
PermissionAction::AllowOnce,
PermissionAction::AllowSession,
PermissionAction::AllowPermanent,
PermissionAction::Deny,
PermissionAction::DenyAlways,
PermissionAction::DenyPermanent,
PermissionAction::Back,
PermissionAction::Exit,
]
.into_iter()
.find(|candidate| candidate.as_str() == wire)
}
/// Express a permission question as an interaction definition.
///
/// # Errors
///
/// [`ProtocolError::InvalidId`] when an action's wire name is not a valid
/// option id. Every `PermissionAction` wire name is, so this can only fire
/// if that frozen set gains a name outside `[A-Za-z0-9_-]`.
pub fn question_to_definition(
question: &Question<PermissionAction>,
) -> Result<InteractionDefinition, ProtocolError> {
let mut options = Vec::with_capacity(question.actions.len());
for action in &question.actions {
options.push(ChoiceOption {
id: OptionId::new(action.value.as_str())?,
role: role_of(action.value),
label: action.label.clone(),
key: action.key.clone(),
aliases: action.aliases.clone(),
});
}
let controls = vec![Control {
id: ControlId::new(DECISION_CONTROL)?,
kind: ControlKind::Choice { options },
label: String::new(),
// A permission prompt must be answered: an unanswered one denies
// by default, which is a decision, not an absence.
requirement: Requirement::Required,
}];
// DERIVED, not hardcoded (#1912). A legacy `Question` may carry two
// actions or five, and this function does not know which until it has
// built the options. Hardcoding `Choice` made the adapter emit
// decision-shaped definitions under the wrong kind for every two-action
// question — the same ambiguity as the two hand-written constructors, but
// generated at runtime from arbitrary input.
let kind = if newt_interaction::controls_are_decision_shaped(&controls) {
InteractionKind::Confirm
} else {
InteractionKind::Choice
};
let mut definition = InteractionDefinition::new(kind, question.markdown.clone(), controls);
definition.note = question.note.clone();
Ok(definition)
}