| 1 | //! Generic handler transport for staged command migration. |
| 2 | //! |
| 3 | //! The output type is generic so FEAT-014 does not move or duplicate the |
| 4 | //! TUI-owned `CommandResult`. During in-place adoption, the TUI instantiates |
| 5 | //! `CommandHandler<crate::commands::CommandResult>`. |
| 6 | |
| 7 | use crate::facets::{ |
| 8 | CommandCostContext, CommandMediaContext, CommandMemoryContext, CommandModePolicyContext, |
| 9 | CommandModelContext, CommandPluginContext, CommandPresentationContext, CommandProjectContext, |
| 10 | CommandSessionContext, CommandSessionControlContext, CommandSessionExportContext, |
| 11 | CommandSessionLifecycleContext, CommandSkillGroupContext, CommandSkillsContext, |
| 12 | CommandSystemPromptContext, CommandWorkspaceContext, |
| 13 | }; |
| 14 | |
| 15 | /// Exact host capabilities exposed to one contextual command handler. |
| 16 | /// |
| 17 | /// The set lives in the external contract crate so command registrations can |
| 18 | /// declare least authority without naming the TUI host. The dispatcher uses |
| 19 | /// the declaration to populate only those slots in [`CommandContexts`]. |
| 20 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] |
| 21 | pub struct CommandCapabilities(u16); |
| 22 | |
| 23 | impl CommandCapabilities { |
| 24 | pub const NONE: Self = Self(0); |
| 25 | pub const SESSION: Self = Self(1 << 0); |
| 26 | pub const MODEL: Self = Self(1 << 1); |
| 27 | pub const COST: Self = Self(1 << 2); |
| 28 | pub const MODE_POLICY: Self = Self(1 << 3); |
| 29 | pub const SYSTEM_PROMPT: Self = Self(1 << 4); |
| 30 | pub const SKILLS: Self = Self(1 << 5); |
| 31 | pub const WORKSPACE: Self = Self(1 << 6); |
| 32 | pub const PRESENTATION: Self = Self(1 << 7); |
| 33 | pub const MEDIA: Self = Self(1 << 8); |
| 34 | /// Memory-group host data (FEAT-019 D1). |
| 35 | pub const MEMORY: Self = Self(1 << 9); |
| 36 | /// Project-group host data (FEAT-021 D1). |
| 37 | pub const PROJECT: Self = Self(1 << 10); |
| 38 | /// Skills-group host data (FEAT-022 D1). |
| 39 | pub const SKILL_GROUP: Self = Self(1 << 11); |
| 40 | /// Plugin-group host data (FEAT-020 D1), appended after current main capabilities. |
| 41 | pub const PLUGIN: Self = Self(1 << 12); |
| 42 | /// Session-lifecycle host data (FEAT-023 D3), the next non-conflicting bit |
| 43 | /// after `PLUGIN`. Required only by the seven host-dependent lifecycle |
| 44 | /// commands; `/compact` and `/purge` remain pure. Never widened by the |
| 45 | /// basic session capability. |
| 46 | pub const SESSION_LIFECYCLE: Self = Self(1 << 13); |
| 47 | /// Session-control host data (FEAT-024 D3), the next non-conflicting bit |
| 48 | /// after `SESSION_LIFECYCLE`. Required only by the six host-dependent |
| 49 | /// control commands (`/relay`, `/rename`, `/resume`, `/rc`, `/remote-env`, |
| 50 | /// `/title`); `/remote-env` also declares `PRESENTATION`. The backing |
| 51 | /// storage remains `u16` per the resolved maintainer review on FEAT-023 PR |
| 52 | /// #5902 — bit 14 is available, so no speculative widening is performed. |
| 53 | pub const SESSION_CONTROL: Self = Self(1 << 14); |
| 54 | /// Session-export host data (FEAT-025 D1), the next non-conflicting bit |
| 55 | /// after `SESSION_CONTROL`. Required only by the host-dependent `/export` |
| 56 | /// command and its `/daochu` alias; every concrete App, snapshot, clipboard, |
| 57 | /// filesystem, history, and turn-handoff access stays behind the TUI export |
| 58 | /// adapter. |
| 59 | /// |
| 60 | /// **Capacity: this is the last free bit.** Bits 0-15 are now fully |
| 61 | /// allocated, so another capability cannot be added without widening the |
| 62 | /// backing storage to `u32`. FEAT-026 (session structcopy) needs its own |
| 63 | /// exact-minimum facet and therefore owns that widening decision; reusing |
| 64 | /// `SESSION_EXPORT` for it would break the least-capability invariant. |
| 65 | /// The `export_capability_space_is_exactly_full` test pins the capacity so |
| 66 | /// the next author gets a deliberate decision instead of a compile error |
| 67 | /// with no context. |
| 68 | pub const SESSION_EXPORT: Self = Self(1 << 15); |
| 69 | |
| 70 | /// Raw bit pattern, for tests that pin the capability-space capacity. |
| 71 | /// |
| 72 | /// Kept `#[cfg(test)]` so the `u16` backing stays an implementation detail |
| 73 | /// and nothing can widen it accidentally through a public accessor. |
| 74 | #[cfg(test)] |
| 75 | pub(crate) const fn bits_for_test(self) -> u16 { |
| 76 | self.0 |
| 77 | } |
| 78 | |
| 79 | pub const fn union(self, other: Self) -> Self { |
| 80 | Self(self.0 | other.0) |
| 81 | } |
| 82 | |
| 83 | pub const fn contains(self, capability: Self) -> bool { |
| 84 | !capability.is_empty() && self.0 & capability.0 == capability.0 |
| 85 | } |
| 86 | |
| 87 | pub const fn is_empty(self) -> bool { |
| 88 | self.0 == 0 |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | impl std::ops::BitOr for CommandCapabilities { |
| 93 | type Output = Self; |
| 94 | |
| 95 | fn bitor(self, rhs: Self) -> Self::Output { |
| 96 | self.union(rhs) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | /// A command handler that is either argument-only or capability-scoped. |
| 101 | #[derive(Clone, Copy)] |
| 102 | pub enum CommandHandler<R> { |
| 103 | Pure(fn(Option<&str>) -> R), |
| 104 | Contextual { |
| 105 | capabilities: CommandCapabilities, |
| 106 | handler: fn(CommandContexts<'_>, Option<&str>) -> R, |
| 107 | }, |
| 108 | } |
| 109 | |
| 110 | /// Transport envelope with one independently optional facet slot. |
| 111 | pub struct CommandContexts<'a> { |
| 112 | session: Option<&'a mut dyn CommandSessionContext>, |
| 113 | model: Option<&'a mut dyn CommandModelContext>, |
| 114 | cost: Option<&'a mut dyn CommandCostContext>, |
| 115 | mode_policy: Option<&'a mut dyn CommandModePolicyContext>, |
| 116 | system_prompt: Option<&'a mut dyn CommandSystemPromptContext>, |
| 117 | skills: Option<&'a mut dyn CommandSkillsContext>, |
| 118 | workspace: Option<&'a mut dyn CommandWorkspaceContext>, |
| 119 | presentation: Option<&'a mut dyn CommandPresentationContext>, |
| 120 | media: Option<&'a mut dyn CommandMediaContext>, |
| 121 | memory: Option<&'a mut dyn CommandMemoryContext>, |
| 122 | project: Option<&'a mut dyn CommandProjectContext>, |
| 123 | skill_group: Option<&'a mut dyn CommandSkillGroupContext>, |
| 124 | plugin: Option<&'a mut dyn CommandPluginContext>, |
| 125 | lifecycle: Option<&'a mut dyn CommandSessionLifecycleContext>, |
| 126 | control: Option<&'a mut dyn CommandSessionControlContext>, |
| 127 | export: Option<&'a mut dyn CommandSessionExportContext>, |
| 128 | } |
| 129 | |
| 130 | /// Consumed envelope used when one handler needs several independent facets. |
| 131 | pub struct ContextParts<'a> { |
| 132 | pub session: Option<&'a mut dyn CommandSessionContext>, |
| 133 | pub model: Option<&'a mut dyn CommandModelContext>, |
| 134 | pub cost: Option<&'a mut dyn CommandCostContext>, |
| 135 | pub mode_policy: Option<&'a mut dyn CommandModePolicyContext>, |
| 136 | pub system_prompt: Option<&'a mut dyn CommandSystemPromptContext>, |
| 137 | pub skills: Option<&'a mut dyn CommandSkillsContext>, |
| 138 | pub workspace: Option<&'a mut dyn CommandWorkspaceContext>, |
| 139 | pub presentation: Option<&'a mut dyn CommandPresentationContext>, |
| 140 | pub media: Option<&'a mut dyn CommandMediaContext>, |
| 141 | pub memory: Option<&'a mut dyn CommandMemoryContext>, |
| 142 | pub project: Option<&'a mut dyn CommandProjectContext>, |
| 143 | pub skill_group: Option<&'a mut dyn CommandSkillGroupContext>, |
| 144 | pub plugin: Option<&'a mut dyn CommandPluginContext>, |
| 145 | pub lifecycle: Option<&'a mut dyn CommandSessionLifecycleContext>, |
| 146 | pub control: Option<&'a mut dyn CommandSessionControlContext>, |
| 147 | pub export: Option<&'a mut dyn CommandSessionExportContext>, |
| 148 | } |
| 149 | |
| 150 | impl<'a> CommandContexts<'a> { |
| 151 | pub fn empty() -> Self { |
| 152 | Self { |
| 153 | session: None, |
| 154 | model: None, |
| 155 | cost: None, |
| 156 | mode_policy: None, |
| 157 | system_prompt: None, |
| 158 | skills: None, |
| 159 | workspace: None, |
| 160 | presentation: None, |
| 161 | media: None, |
| 162 | memory: None, |
| 163 | project: None, |
| 164 | skill_group: None, |
| 165 | plugin: None, |
| 166 | lifecycle: None, |
| 167 | control: None, |
| 168 | export: None, |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | pub fn into_parts(self) -> ContextParts<'a> { |
| 173 | ContextParts { |
| 174 | session: self.session, |
| 175 | model: self.model, |
| 176 | cost: self.cost, |
| 177 | mode_policy: self.mode_policy, |
| 178 | system_prompt: self.system_prompt, |
| 179 | skills: self.skills, |
| 180 | workspace: self.workspace, |
| 181 | presentation: self.presentation, |
| 182 | media: self.media, |
| 183 | memory: self.memory, |
| 184 | project: self.project, |
| 185 | skill_group: self.skill_group, |
| 186 | plugin: self.plugin, |
| 187 | lifecycle: self.lifecycle, |
| 188 | control: self.control, |
| 189 | export: self.export, |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | pub fn with_session(mut self, value: &'a mut dyn CommandSessionContext) -> Self { |
| 194 | assert!( |
| 195 | self.session.replace(value).is_none(), |
| 196 | "session facet already set" |
| 197 | ); |
| 198 | self |
| 199 | } |
| 200 | |
| 201 | pub fn with_model(mut self, value: &'a mut dyn CommandModelContext) -> Self { |
| 202 | assert!( |
| 203 | self.model.replace(value).is_none(), |
| 204 | "model facet already set" |
| 205 | ); |
| 206 | self |
| 207 | } |
| 208 | |
| 209 | pub fn with_cost(mut self, value: &'a mut dyn CommandCostContext) -> Self { |
| 210 | assert!(self.cost.replace(value).is_none(), "cost facet already set"); |
| 211 | self |
| 212 | } |
| 213 | |
| 214 | pub fn with_mode_policy(mut self, value: &'a mut dyn CommandModePolicyContext) -> Self { |
| 215 | assert!( |
| 216 | self.mode_policy.replace(value).is_none(), |
| 217 | "mode-policy facet already set" |
| 218 | ); |
| 219 | self |
| 220 | } |
| 221 | |
| 222 | pub fn with_system_prompt(mut self, value: &'a mut dyn CommandSystemPromptContext) -> Self { |
| 223 | assert!( |
| 224 | self.system_prompt.replace(value).is_none(), |
| 225 | "system-prompt facet already set" |
| 226 | ); |
| 227 | self |
| 228 | } |
| 229 | |
| 230 | pub fn with_skills(mut self, value: &'a mut dyn CommandSkillsContext) -> Self { |
| 231 | assert!( |
| 232 | self.skills.replace(value).is_none(), |
| 233 | "skills facet already set" |
| 234 | ); |
| 235 | self |
| 236 | } |
| 237 | |
| 238 | pub fn with_workspace(mut self, value: &'a mut dyn CommandWorkspaceContext) -> Self { |
| 239 | assert!( |
| 240 | self.workspace.replace(value).is_none(), |
| 241 | "workspace facet already set" |
| 242 | ); |
| 243 | self |
| 244 | } |
| 245 | |
| 246 | pub fn with_presentation(mut self, value: &'a mut dyn CommandPresentationContext) -> Self { |
| 247 | assert!( |
| 248 | self.presentation.replace(value).is_none(), |
| 249 | "presentation facet already set" |
| 250 | ); |
| 251 | self |
| 252 | } |
| 253 | |
| 254 | pub fn with_media(mut self, value: &'a mut dyn CommandMediaContext) -> Self { |
| 255 | assert!( |
| 256 | self.media.replace(value).is_none(), |
| 257 | "media facet already set" |
| 258 | ); |
| 259 | self |
| 260 | } |
| 261 | |
| 262 | pub fn with_memory(mut self, value: &'a mut dyn CommandMemoryContext) -> Self { |
| 263 | assert!( |
| 264 | self.memory.replace(value).is_none(), |
| 265 | "memory facet already set" |
| 266 | ); |
| 267 | self |
| 268 | } |
| 269 | |
| 270 | pub fn with_project(mut self, value: &'a mut dyn CommandProjectContext) -> Self { |
| 271 | assert!( |
| 272 | self.project.replace(value).is_none(), |
| 273 | "project facet already set" |
| 274 | ); |
| 275 | self |
| 276 | } |
| 277 | |
| 278 | pub fn with_skill_group(mut self, value: &'a mut dyn CommandSkillGroupContext) -> Self { |
| 279 | assert!( |
| 280 | self.skill_group.replace(value).is_none(), |
| 281 | "skill-group facet already set" |
| 282 | ); |
| 283 | self |
| 284 | } |
| 285 | |
| 286 | pub fn with_plugin(mut self, value: &'a mut dyn CommandPluginContext) -> Self { |
| 287 | assert!( |
| 288 | self.plugin.replace(value).is_none(), |
| 289 | "plugin facet already set" |
| 290 | ); |
| 291 | self |
| 292 | } |
| 293 | |
| 294 | pub fn with_lifecycle(mut self, value: &'a mut dyn CommandSessionLifecycleContext) -> Self { |
| 295 | assert!( |
| 296 | self.lifecycle.replace(value).is_none(), |
| 297 | "lifecycle facet already set" |
| 298 | ); |
| 299 | self |
| 300 | } |
| 301 | |
| 302 | pub fn with_control(mut self, value: &'a mut dyn CommandSessionControlContext) -> Self { |
| 303 | assert!( |
| 304 | self.control.replace(value).is_none(), |
| 305 | "control facet already set" |
| 306 | ); |
| 307 | self |
| 308 | } |
| 309 | |
| 310 | pub fn with_export(mut self, value: &'a mut dyn CommandSessionExportContext) -> Self { |
| 311 | assert!( |
| 312 | self.export.replace(value).is_none(), |
| 313 | "export facet already set" |
| 314 | ); |
| 315 | self |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | impl Default for CommandContexts<'_> { |
| 320 | fn default() -> Self { |
| 321 | Self::empty() |
| 322 | } |
| 323 | } |
| 324 |