1use std::collections::BTreeSet;
7use std::path::PathBuf;
8
9use sqry_core::plugin::PluginManager;
10
11pub mod feature_table;
12pub use feature_table::{
13 PLUGIN_FEATURE_TABLE, PluginFeatureSpec, all_unknown_ids_have_features, missing_features_for,
14};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum PluginCostTier {
19 Fast,
20 HighWallClock,
21 Optional,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25#[non_exhaustive]
26pub enum HighCostMode {
27 #[default]
28 FastPathDefault,
29 IncludeAll,
30 ExcludeAll,
31}
32
33impl HighCostMode {
34 #[must_use]
35 pub const fn as_str(self) -> &'static str {
36 match self {
37 Self::FastPathDefault => "fast_path_default",
38 Self::IncludeAll => "include_all",
39 Self::ExcludeAll => "exclude_all",
40 }
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Default)]
45pub struct PluginSelectionConfig {
46 pub high_cost_mode: HighCostMode,
47 pub enable_plugins: BTreeSet<String>,
48 pub disable_plugins: BTreeSet<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct PluginSelectionResolution {
53 pub high_cost_mode: HighCostMode,
54 pub active_plugin_ids: Vec<String>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum PluginSelectionError {
60 #[deprecated(note = "use UnknownPluginIdsCtx for richer diagnostics (cluster-E §E.2)")]
64 UnknownPluginIds {
65 ids: Vec<String>,
66 supported_ids: Vec<String>,
67 },
68 UnknownPluginIdsCtx {
72 ids: Vec<String>,
74 supported_ids: Vec<String>,
76 manifest_path: Option<PathBuf>,
80 suggested_features: Vec<&'static str>,
85 all_unknown_ids_have_features: bool,
89 },
90}
91
92impl std::fmt::Display for PluginSelectionError {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 match self {
95 #[allow(deprecated)]
96 Self::UnknownPluginIds { ids, supported_ids } => write!(
97 f,
98 "unknown plugin ids: {} (supported ids: {})",
99 ids.join(", "),
100 supported_ids.join(", ")
101 ),
102 Self::UnknownPluginIdsCtx {
103 ids,
104 supported_ids,
105 manifest_path,
106 suggested_features,
107 all_unknown_ids_have_features,
108 } => {
109 writeln!(
110 f,
111 "unknown plugin ids: {} (this binary supports: {})",
112 ids.join(", "),
113 supported_ids.join(", ")
114 )?;
115 if let Some(p) = manifest_path {
116 writeln!(f, " manifest: {}", p.display())?;
117 }
118 if !suggested_features.is_empty() {
119 writeln!(
120 f,
121 " rebuild this binary with: cargo install --path sqry-cli --features {}",
122 suggested_features.join(",")
123 )?;
124 }
125 if *all_unknown_ids_have_features {
126 write!(
127 f,
128 " …or rebuild the index with the binary that produced it: \
129 sqry index --force <workspace-root>"
130 )
131 } else {
132 write!(
133 f,
134 " the unknown ids do not match any known feature flag — \
135 the manifest may be from a newer sqry version. Rebuild \
136 the index: sqry index --force <workspace-root>"
137 )
138 }
139 }
140 }
141 }
142}
143
144impl std::error::Error for PluginSelectionError {}
145
146#[derive(Debug, Clone, Copy)]
147pub struct BuiltinPluginSpec {
148 pub id: &'static str,
149 pub cost_tier: PluginCostTier,
150 pub register: fn(&mut PluginManager),
151}
152
153macro_rules! builtin_plugin_specs {
154 ($($(#[$meta:meta])* [$id:literal, $tier:expr, $register:expr]),+ $(,)?) => {
155 const BUILTIN_PLUGIN_SPECS: &[BuiltinPluginSpec] = &[
156 $(
157 $(#[$meta])*
158 BuiltinPluginSpec {
159 id: $id,
160 cost_tier: $tier,
161 register: $register,
162 },
163 )+
164 ];
165 };
166}
167
168builtin_plugin_specs!(
169 ["c", PluginCostTier::Fast, |pm| pm.register_builtin(
170 Box::new(sqry_lang_c::CPlugin::default())
171 )],
172 ["cpp", PluginCostTier::Fast, |pm| pm.register_builtin(
173 Box::new(sqry_lang_cpp::CppPlugin::default())
174 )],
175 ["csharp", PluginCostTier::Fast, |pm| pm.register_builtin(
176 Box::new(sqry_lang_csharp::CSharpPlugin::default())
177 )],
178 ["css", PluginCostTier::Fast, |pm| pm.register_builtin(
179 Box::new(sqry_lang_css::CssPlugin::default())
180 )],
181 ["dart", PluginCostTier::Fast, |pm| pm.register_builtin(
182 Box::new(sqry_lang_dart::DartPlugin::default())
183 )],
184 ["elixir", PluginCostTier::Fast, |pm| pm.register_builtin(
185 Box::new(sqry_lang_elixir::ElixirPlugin::default())
186 )],
187 ["go", PluginCostTier::Fast, |pm| pm.register_builtin(
188 Box::new(sqry_lang_go::GoPlugin::default())
189 )],
190 ["groovy", PluginCostTier::Fast, |pm| pm.register_builtin(
191 Box::new(sqry_lang_groovy::GroovyPlugin::default())
192 )],
193 ["haskell", PluginCostTier::Fast, |pm| pm.register_builtin(
194 Box::new(sqry_lang_haskell::HaskellPlugin::default())
195 )],
196 ["html", PluginCostTier::Fast, |pm| pm.register_builtin(
197 Box::new(sqry_lang_html::HtmlPlugin::default())
198 )],
199 ["java", PluginCostTier::Fast, |pm| pm.register_builtin(
200 Box::new(sqry_lang_java::JavaPlugin::default())
201 )],
202 ["javascript", PluginCostTier::Fast, |pm| pm
203 .register_builtin(Box::new(
204 sqry_lang_javascript::JavaScriptPlugin::default()
205 ))],
206 ["kotlin", PluginCostTier::Fast, |pm| pm.register_builtin(
207 Box::new(sqry_lang_kotlin::KotlinPlugin::default())
208 )],
209 ["lua", PluginCostTier::Fast, |pm| pm.register_builtin(
210 Box::new(sqry_lang_lua::LuaPlugin::default())
211 )],
212 ["perl", PluginCostTier::Fast, |pm| pm.register_builtin(
213 Box::new(sqry_lang_perl::PerlPlugin::default())
214 )],
215 ["php", PluginCostTier::Fast, |pm| pm.register_builtin(
216 Box::new(sqry_lang_php::PhpPlugin::default())
217 )],
218 ["python", PluginCostTier::Fast, |pm| pm.register_builtin(
219 Box::new(sqry_lang_python::PythonPlugin::default())
220 )],
221 ["r", PluginCostTier::Fast, |pm| pm.register_builtin(
222 Box::new(sqry_lang_r::RPlugin::default())
223 )],
224 ["ruby", PluginCostTier::Fast, |pm| pm.register_builtin(
225 Box::new(sqry_lang_ruby::RubyPlugin::default())
226 )],
227 ["rust", PluginCostTier::Fast, |pm| pm.register_builtin(
228 Box::new(sqry_lang_rust::RustPlugin::default())
229 )],
230 ["scala", PluginCostTier::Fast, |pm| pm.register_builtin(
231 Box::new(sqry_lang_scala::ScalaPlugin::default())
232 )],
233 ["shell", PluginCostTier::Fast, |pm| pm.register_builtin(
234 Box::new(sqry_lang_shell::ShellPlugin::default())
235 )],
236 ["sql", PluginCostTier::Fast, |pm| pm.register_builtin(
237 Box::new(sqry_lang_sql::SqlPlugin::default())
238 )],
239 ["svelte", PluginCostTier::Fast, |pm| pm.register_builtin(
240 Box::new(sqry_lang_svelte::SveltePlugin::default())
241 )],
242 ["swift", PluginCostTier::Fast, |pm| pm.register_builtin(
243 Box::new(sqry_lang_swift::SwiftPlugin::default())
244 )],
245 ["typescript", PluginCostTier::Fast, |pm| pm
246 .register_builtin(Box::new(
247 sqry_lang_typescript::TypeScriptPlugin::default()
248 ))],
249 ["vue", PluginCostTier::Fast, |pm| pm.register_builtin(
250 Box::new(sqry_lang_vue::VuePlugin::default())
251 )],
252 ["zig", PluginCostTier::Fast, |pm| pm.register_builtin(
253 Box::new(sqry_lang_zig::ZigPlugin::default())
254 )],
255 ["plsql", PluginCostTier::Fast, |pm| pm.register_builtin(
256 Box::new(sqry_lang_oracle_plsql::OraclePlsqlPlugin::default())
257 )],
258 #[cfg(feature = "plugin-apex")]
259 ["apex", PluginCostTier::Optional, |pm| pm.register_builtin(
260 Box::new(sqry_lang_salesforce_apex::SalesforceApexPlugin::default())
261 )],
262 #[cfg(feature = "plugin-abap")]
263 ["abap", PluginCostTier::Optional, |pm| pm.register_builtin(
264 Box::new(sqry_lang_sap_abap::SapAbapPlugin::default())
265 )],
266 #[cfg(feature = "plugin-servicenow-xanadu")]
267 ["servicenow-xanadu-js", PluginCostTier::Optional, |pm| pm
268 .register_builtin(Box::new(
269 sqry_lang_servicenow_xanadu::ServiceNowXanaduPlugin::default()
270 ))],
271 #[cfg(feature = "plugin-servicenow-xml")]
272 ["servicenow-xml", PluginCostTier::Optional, |pm| pm
273 .register_builtin(Box::new(
274 sqry_lang_servicenow_xml::ServiceNowXmlPlugin::default()
275 ))],
276 #[cfg(feature = "plugin-terraform")]
277 ["terraform", PluginCostTier::Optional, |pm| pm
278 .register_builtin(Box::new(
279 sqry_lang_terraform::TerraformPlugin::default()
280 ))],
281 #[cfg(feature = "plugin-puppet")]
282 ["puppet", PluginCostTier::Optional, |pm| pm
283 .register_builtin(Box::new(
284 sqry_lang_puppet::PuppetPlugin::default()
285 ))],
286 #[cfg(feature = "plugin-pulumi")]
287 ["pulumi", PluginCostTier::Optional, |pm| pm
288 .register_builtin(Box::new(
289 sqry_lang_pulumi::PulumiPlugin::default()
290 ))],
291 ["json", PluginCostTier::HighWallClock, |pm| pm
292 .register_builtin(Box::new(
293 sqry_lang_json::JsonPlugin::new()
294 ))]
295);
296
297#[must_use]
298pub fn builtin_plugin_ids() -> Vec<String> {
299 BUILTIN_PLUGIN_SPECS
300 .iter()
301 .map(|spec| spec.id.to_string())
302 .collect()
303}
304
305#[must_use]
307pub fn create_plugin_manager() -> PluginManager {
308 let resolution = resolve_plugin_selection(&PluginSelectionConfig::default())
309 .unwrap_or_else(|_| unreachable!("default plugin selection must resolve"));
310 create_plugin_manager_for_plugin_ids(&resolution.active_plugin_ids)
311 .unwrap_or_else(|_| unreachable!("default plugin ids must be valid"))
312}
313
314#[must_use]
316pub fn create_plugin_manager_all() -> PluginManager {
317 create_plugin_manager_with_config(&PluginSelectionConfig {
318 high_cost_mode: HighCostMode::IncludeAll,
319 ..PluginSelectionConfig::default()
320 })
321 .unwrap_or_else(|_| unreachable!("full built-in plugin roster must resolve"))
322}
323
324pub fn create_plugin_manager_with_config(
330 config: &PluginSelectionConfig,
331) -> Result<PluginManager, PluginSelectionError> {
332 let resolution = resolve_plugin_selection(config)?;
333 create_plugin_manager_for_plugin_ids(&resolution.active_plugin_ids)
334}
335
336pub fn create_plugin_manager_for_plugin_ids(
342 plugin_ids: &[String],
343) -> Result<PluginManager, PluginSelectionError> {
344 validate_plugin_ids(plugin_ids.iter().map(String::as_str))?;
345
346 let selected_ids: BTreeSet<&str> = plugin_ids.iter().map(String::as_str).collect();
347 let mut pm = PluginManager::new();
348
349 for spec in BUILTIN_PLUGIN_SPECS {
350 if selected_ids.contains(spec.id) {
351 (spec.register)(&mut pm);
352 }
353 }
354
355 Ok(pm)
356}
357
358pub fn resolve_plugin_selection(
364 config: &PluginSelectionConfig,
365) -> Result<PluginSelectionResolution, PluginSelectionError> {
366 validate_plugin_ids(
367 config
368 .enable_plugins
369 .iter()
370 .map(String::as_str)
371 .chain(config.disable_plugins.iter().map(String::as_str)),
372 )?;
373
374 let active_plugin_ids = BUILTIN_PLUGIN_SPECS
375 .iter()
376 .filter(|spec| is_plugin_enabled(spec, config))
377 .map(|spec| spec.id.to_string())
378 .collect();
379
380 Ok(PluginSelectionResolution {
381 high_cost_mode: config.high_cost_mode,
382 active_plugin_ids,
383 })
384}
385
386fn is_plugin_enabled(spec: &BuiltinPluginSpec, config: &PluginSelectionConfig) -> bool {
387 if config.disable_plugins.contains(spec.id) {
388 return false;
389 }
390 if config.enable_plugins.contains(spec.id) {
391 return true;
392 }
393
394 match config.high_cost_mode {
395 HighCostMode::IncludeAll => true,
396 HighCostMode::FastPathDefault | HighCostMode::ExcludeAll => {
397 matches!(spec.cost_tier, PluginCostTier::Fast)
398 }
399 }
400}
401
402fn validate_plugin_ids<'a>(
403 plugin_ids: impl IntoIterator<Item = &'a str>,
404) -> Result<(), PluginSelectionError> {
405 let supported_ids: BTreeSet<&str> = BUILTIN_PLUGIN_SPECS.iter().map(|spec| spec.id).collect();
406 let mut unknown_ids = plugin_ids
407 .into_iter()
408 .filter(|id| !supported_ids.contains(id))
409 .map(ToString::to_string)
410 .collect::<Vec<_>>();
411 unknown_ids.sort();
412 unknown_ids.dedup();
413
414 if unknown_ids.is_empty() {
415 return Ok(());
416 }
417
418 let suggested_features = missing_features_for(&unknown_ids);
419 let all_have_features = all_unknown_ids_have_features(&unknown_ids);
420 Err(PluginSelectionError::UnknownPluginIdsCtx {
421 ids: unknown_ids,
422 supported_ids: supported_ids.into_iter().map(ToString::to_string).collect(),
423 manifest_path: None,
424 suggested_features,
425 all_unknown_ids_have_features: all_have_features,
426 })
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 fn config_with_mode(high_cost_mode: HighCostMode) -> PluginSelectionConfig {
434 PluginSelectionConfig {
435 high_cost_mode,
436 ..PluginSelectionConfig::default()
437 }
438 }
439
440 #[test]
441 fn test_default_fast_path_excludes_high_cost_plugins() {
442 let pm = create_plugin_manager();
443 let plugins = pm.plugins();
444 let expected_len = BUILTIN_PLUGIN_SPECS
445 .iter()
446 .filter(|spec| matches!(spec.cost_tier, PluginCostTier::Fast))
447 .count();
448
449 assert_eq!(plugins.len(), expected_len);
450 assert!(pm.plugin_by_id("json").is_none());
451 #[cfg(feature = "plugin-servicenow-xml")]
452 assert!(pm.plugin_by_id("servicenow-xml").is_none());
453 }
454
455 #[test]
456 fn test_create_plugin_manager_has_rust() {
457 let pm = create_plugin_manager();
458 assert!(pm.plugin_for_extension("rs").is_some());
459 assert!(pm.plugin_by_id("rust").is_some());
460 }
461
462 #[test]
463 fn test_include_all_restores_high_cost_plugins() {
464 let pm = create_plugin_manager_with_config(&config_with_mode(HighCostMode::IncludeAll))
465 .expect("include-all config should be valid");
466
467 assert!(pm.plugin_by_id("json").is_some());
468 #[cfg(feature = "plugin-servicenow-xml")]
469 assert!(pm.plugin_by_id("servicenow-xml").is_some());
470 }
471
472 #[test]
473 fn test_explicit_enable_beats_fast_path_default() {
474 let mut config = PluginSelectionConfig::default();
475 config.enable_plugins.insert("json".to_string());
476
477 let resolution = resolve_plugin_selection(&config).expect("selection should resolve");
478 assert!(resolution.active_plugin_ids.iter().any(|id| id == "json"));
479 }
480
481 #[test]
482 fn test_explicit_disable_beats_explicit_enable() {
483 let mut config = config_with_mode(HighCostMode::IncludeAll);
484 config.enable_plugins.insert("json".to_string());
485 config.disable_plugins.insert("json".to_string());
486
487 let resolution = resolve_plugin_selection(&config).expect("selection should resolve");
488 assert!(!resolution.active_plugin_ids.iter().any(|id| id == "json"));
489 }
490
491 #[test]
492 fn test_unknown_plugin_ids_fail_validation() {
493 let mut config = PluginSelectionConfig::default();
494 config.enable_plugins.insert("missing-plugin".to_string());
495
496 let err = resolve_plugin_selection(&config).expect_err("selection should fail");
497 assert!(
498 matches!(err, PluginSelectionError::UnknownPluginIdsCtx { .. }),
499 "unexpected error: {err}"
500 );
501 }
502
503 #[test]
508 fn display_includes_manifest_and_feature_hint() {
509 let err = PluginSelectionError::UnknownPluginIdsCtx {
512 ids: vec!["terraform".to_string(), "made-up".to_string()],
513 supported_ids: vec!["rust".to_string(), "go".to_string()],
514 manifest_path: Some(PathBuf::from("/repo/proj/.sqry/graph/manifest.json")),
515 suggested_features: vec!["plugin-terraform"],
516 all_unknown_ids_have_features: false,
517 };
518 let rendered = err.to_string();
519 assert!(
520 rendered.contains("terraform"),
521 "must list every unknown id, got: {rendered}"
522 );
523 assert!(
524 rendered.contains("/repo/proj/.sqry/graph/manifest.json"),
525 "must include the manifest path, got: {rendered}"
526 );
527 assert!(
528 rendered.contains("--features plugin-terraform"),
529 "must include the rebuild-with-features suggestion, got: {rendered}"
530 );
531 assert!(
532 rendered.contains("Rebuild the index"),
533 "must include the rebuild-the-index suggestion when not all ids have features, \
534 got: {rendered}"
535 );
536 }
537
538 #[test]
539 fn test_spec_id_matches_registered_plugin_id() {
540 for spec in BUILTIN_PLUGIN_SPECS {
541 let mut pm = PluginManager::new();
542 (spec.register)(&mut pm);
543
544 let plugin = pm
545 .plugin_by_id(spec.id)
546 .unwrap_or_else(|| panic!("registry spec {} did not register by id", spec.id));
547 assert_eq!(plugin.metadata().id, spec.id);
548 }
549 }
550}