返回 CodeWhale
features.rs
根目录 / crates / tui / src / features.rs
1 //! Feature flags and metadata for codewhale.
2
3 use std::collections::{BTreeMap, BTreeSet};
4 use std::fmt::{self, Write as _};
5
6 use serde::{Deserialize, Deserializer, Serialize, de};
7
8 /// Lifecycle stage for a feature flag.
9 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
10 pub enum Stage {
11 Experimental,
12 Beta,
13 Stable,
14 }
15
16 impl Stage {
17 pub fn as_str(self) -> &'static str {
18 match self {
19 Self::Experimental => "experimental",
20 Self::Beta => "beta",
21 Self::Stable => "stable",
22 }
23 }
24 }
25
26 /// Unique features toggled via configuration.
27 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
28 pub enum Feature {
29 /// Enable the default shell tool.
30 ShellTool,
31 /// Enable background sub-agent tooling.
32 Subagents,
33 /// Enable web search tool.
34 WebSearch,
35 /// Enable apply_patch tool.
36 ApplyPatch,
37 /// Enable MCP tools.
38 Mcp,
39 /// Enable execpolicy integration/tooling.
40 ExecPolicy,
41 /// Enable vision model for image analysis.
42 VisionModel,
43 /// Enable the agent-callable `verify` adversarial self-critique tool (#4196).
44 Verify,
45 /// Expose `execute_tools` eagerly so the model composes by default (CodeMode).
46 CodeMode,
47 /// Run reviewed plugins' `native` host code in the TypeScript extension
48 /// host (experimental). Toggling it changes the plugin activation policy,
49 /// so every plugin is re-reviewed after a restart, in either direction.
50 ExtensionHost,
51 /// Use the pinned TypeScript finance normalization adapter; no runtime fallback.
52 FinanceHost,
53 /// Use the pinned TypeScript data validation presenter; no runtime fallback.
54 DataHost,
55 /// Use pinned speech preparation for the CLI and tool; no runtime fallback.
56 SpeechHost,
57 /// Pinned PDF outcome orchestration; Core retains process and document bytes.
58 PdfHost,
59 /// Pinned Native/Tesseract selection; Core retains image/process authority.
60 OcrHost,
61 /// Pinned web query/provider adapters; Core retains all HTTP and policy.
62 WebSearchHost,
63 /// Pinned HTML region selection; complete document bytes stay in Core.
64 WebExtractHost,
65 /// Pinned GitHub and local report presentation; Core retains effects and disclosure.
66 GithubHost,
67 /// Captured review presentation; Core retains provider, evidence and publication.
68 ReviewHost,
69 }
70
71 impl fmt::Display for Stage {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.write_str(self.as_str())
74 }
75 }
76
77 /// Holds the effective set of enabled features.
78 #[derive(Debug, Clone, Default, PartialEq)]
79 pub struct Features {
80 enabled: BTreeSet<Feature>,
81 }
82
83 impl Features {
84 /// Starts with built-in defaults.
85 pub fn with_defaults() -> Self {
86 let mut set = BTreeSet::new();
87 for spec in FEATURES {
88 if spec.default_enabled {
89 set.insert(spec.id);
90 }
91 }
92 Self { enabled: set }
93 }
94
95 pub fn enabled(&self, feature: Feature) -> bool {
96 self.enabled.contains(&feature)
97 }
98
99 pub fn enable(&mut self, feature: Feature) -> &mut Self {
100 self.enabled.insert(feature);
101 self
102 }
103
104 pub fn disable(&mut self, feature: Feature) -> &mut Self {
105 self.enabled.remove(&feature);
106 self
107 }
108
109 pub fn apply_map(&mut self, entries: &BTreeMap<String, bool>) {
110 for (key, enabled) in entries {
111 if let Some(feature) = feature_from_key(key) {
112 if *enabled {
113 self.enable(feature);
114 } else {
115 self.disable(feature);
116 }
117 }
118 }
119 }
120 }
121
122 /// Keys accepted in `[features]` tables.
123 pub fn is_known_feature_key(key: &str) -> bool {
124 FEATURES.iter().any(|spec| spec.key == key)
125 }
126
127 pub fn feature_from_key(key: &str) -> Option<Feature> {
128 FEATURES
129 .iter()
130 .find(|spec| spec.key == key)
131 .map(|spec| spec.id)
132 }
133
134 pub fn render_feature_table(features: &Features) -> String {
135 let mut output = String::from("feature\tstage\tenabled\n");
136 for spec in FEATURES {
137 let _ = writeln!(
138 output,
139 "{}\t{}\t{}",
140 spec.key,
141 spec.stage,
142 features.enabled(spec.id)
143 );
144 }
145 output
146 }
147
148 /// Deserializable features table for TOML.
149 #[derive(Serialize, Debug, Clone, Default, PartialEq)]
150 pub struct FeaturesToml {
151 #[serde(flatten)]
152 pub entries: BTreeMap<String, bool>,
153 }
154
155 impl<'de> Deserialize<'de> for FeaturesToml {
156 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
157 where
158 D: Deserializer<'de>,
159 {
160 let raw = BTreeMap::<String, toml::Value>::deserialize(deserializer)?;
161 let mut entries = BTreeMap::new();
162
163 for (key, value) in raw {
164 match value {
165 toml::Value::Boolean(enabled) => {
166 entries.insert(key, enabled);
167 }
168 toml::Value::Table(table) if key == "enabled" => {
169 for (feature_key, feature_value) in table {
170 match feature_value {
171 toml::Value::Boolean(enabled) => {
172 entries.insert(feature_key, enabled);
173 }
174 other => {
175 return Err(de::Error::custom(format!(
176 "features.enabled.{feature_key} must be a boolean, got {other:?}"
177 )));
178 }
179 }
180 }
181 }
182 other if is_known_feature_key(&key) => {
183 return Err(de::Error::custom(format!(
184 "features.{key} must be a boolean, got {other:?}"
185 )));
186 }
187 _ => {}
188 }
189 }
190
191 Ok(Self { entries })
192 }
193 }
194
195 /// Single registry of all feature definitions.
196 #[derive(Debug, Clone, Copy)]
197 pub struct FeatureSpec {
198 pub id: Feature,
199 pub key: &'static str,
200 pub stage: Stage,
201 pub default_enabled: bool,
202 }
203
204 pub const FEATURES: &[FeatureSpec] = &[
205 FeatureSpec {
206 id: Feature::ShellTool,
207 key: "shell_tool",
208 stage: Stage::Stable,
209 default_enabled: true,
210 },
211 FeatureSpec {
212 id: Feature::Subagents,
213 key: "subagents",
214 stage: Stage::Stable,
215 default_enabled: true,
216 },
217 FeatureSpec {
218 id: Feature::WebSearch,
219 key: "web_search",
220 stage: Stage::Stable,
221 default_enabled: true,
222 },
223 FeatureSpec {
224 id: Feature::ApplyPatch,
225 key: "apply_patch",
226 stage: Stage::Stable,
227 default_enabled: true,
228 },
229 FeatureSpec {
230 id: Feature::Mcp,
231 key: "mcp",
232 stage: Stage::Stable,
233 default_enabled: true,
234 },
235 FeatureSpec {
236 id: Feature::ExecPolicy,
237 key: "exec_policy",
238 stage: Stage::Stable,
239 default_enabled: true,
240 },
241 FeatureSpec {
242 id: Feature::VisionModel,
243 key: "vision_model",
244 stage: Stage::Beta,
245 default_enabled: false,
246 },
247 FeatureSpec {
248 id: Feature::Verify,
249 key: "verify_tool",
250 stage: Stage::Stable,
251 default_enabled: true,
252 },
253 FeatureSpec {
254 id: Feature::CodeMode,
255 key: "code_mode",
256 // Stays listed as experimental so `/config` shows the escape hatch.
257 stage: Stage::Experimental,
258 // #6562: code mode is the default way MCP and plugin tools compose.
259 // `[features] code_mode = false` is the escape hatch: execute_tools
260 // goes back to deferred (reachable through tool_search).
261 default_enabled: true,
262 },
263 FeatureSpec {
264 id: Feature::FinanceHost,
265 key: "finance_host",
266 stage: Stage::Experimental,
267 default_enabled: false,
268 },
269 FeatureSpec {
270 id: Feature::DataHost,
271 key: "data_host",
272 stage: Stage::Experimental,
273 default_enabled: false,
274 },
275 FeatureSpec {
276 id: Feature::SpeechHost,
277 key: "speech_host",
278 stage: Stage::Experimental,
279 default_enabled: false,
280 },
281 FeatureSpec {
282 id: Feature::PdfHost,
283 key: "pdf_host",
284 stage: Stage::Experimental,
285 default_enabled: false,
286 },
287 FeatureSpec {
288 id: Feature::OcrHost,
289 key: "ocr_host",
290 stage: Stage::Experimental,
291 default_enabled: false,
292 },
293 FeatureSpec {
294 id: Feature::GithubHost,
295 key: "github_host",
296 stage: Stage::Experimental,
297 default_enabled: false,
298 },
299 FeatureSpec {
300 id: Feature::WebSearchHost,
301 key: "web_search_host",
302 stage: Stage::Experimental,
303 default_enabled: false,
304 },
305 FeatureSpec {
306 id: Feature::WebExtractHost,
307 key: "web_extract_host",
308 stage: Stage::Experimental,
309 default_enabled: false,
310 },
311 FeatureSpec {
312 id: Feature::ReviewHost,
313 key: "review_host",
314 stage: Stage::Experimental,
315 default_enabled: false,
316 },
317 FeatureSpec {
318 id: Feature::ExtensionHost,
319 key: "extension_host",
320 stage: Stage::Experimental,
321 default_enabled: false,
322 },
323 ];
324
325 #[cfg(test)]
326 mod tests {
327 use super::*;
328
329 #[test]
330 fn apply_map_toggles_known_features_and_ignores_unknown_keys() {
331 let mut features = Features::with_defaults();
332 let entries = BTreeMap::from([
333 ("mcp".to_string(), false),
334 ("shell_tool".to_string(), false),
335 ("not_real".to_string(), false),
336 ]);
337
338 features.apply_map(&entries);
339
340 assert!(!features.enabled(Feature::Mcp));
341 assert!(!features.enabled(Feature::ShellTool));
342 assert_eq!(feature_from_key("not_real"), None);
343 }
344
345 #[test]
346 fn code_mode_flag_parses_and_defaults_on_with_an_off_switch() {
347 assert_eq!(feature_from_key("code_mode"), Some(Feature::CodeMode));
348 let mut features = Features::with_defaults();
349 assert!(features.enabled(Feature::CodeMode));
350 features.apply_map(&BTreeMap::from([("code_mode".to_string(), false)]));
351 assert!(!features.enabled(Feature::CodeMode));
352 }
353
354 #[test]
355 fn extension_host_flag_parses_and_defaults_off() {
356 assert_eq!(
357 feature_from_key("extension_host"),
358 Some(Feature::ExtensionHost)
359 );
360 assert!(!Features::with_defaults().enabled(Feature::ExtensionHost));
361 }
362
363 #[test]
364 fn render_feature_table_uses_registry_order_and_effective_state() {
365 let mut features = Features::with_defaults();
366 features.disable(Feature::Mcp);
367
368 let table = render_feature_table(&features);
369 let lines = table.lines().collect::<Vec<_>>();
370
371 assert_eq!(lines.first(), Some(&"feature\tstage\tenabled"));
372 assert!(lines.contains(&"shell_tool\tstable\ttrue"));
373 assert!(lines.contains(&"mcp\tstable\tfalse"));
374 }
375 }
376
376 lines RUST