1use std::collections::BTreeSet;
4use std::path::Path;
5
6use anyhow::{Context, Result, bail};
7use sqry_core::graph::unified::persistence::{GraphStorage, PluginSelectionManifest};
8use sqry_core::plugin::PluginManager;
9use sqry_plugin_registry::{
10 HighCostMode, PluginSelectionConfig, PluginSelectionResolution,
11 create_plugin_manager_for_plugin_ids,
12 resolve_plugin_selection as resolve_registry_plugin_selection,
13};
14
15use crate::args::{Cli, PluginSelectionArgs};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ResolvedPluginSelection {
19 pub active_plugin_ids: Vec<String>,
20 pub high_cost_mode: Option<String>,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum PluginSelectionMode {
25 FreshWrite,
26 ExistingWrite,
27 ReadOnly,
28 Diff,
29}
30
31pub struct ResolvedPluginManager {
32 pub plugin_manager: PluginManager,
33 pub persisted_selection: Option<PluginSelectionManifest>,
34}
35
36#[must_use]
37pub fn create_plugin_manager() -> PluginManager {
38 sqry_plugin_registry::create_plugin_manager()
39}
40
41pub fn resolve_plugin_selection(
48 cli: &Cli,
49 root: &Path,
50 mode: PluginSelectionMode,
51) -> Result<ResolvedPluginManager> {
52 let selection_args = cli.plugin_selection_args();
53 let selection = match mode {
54 PluginSelectionMode::FreshWrite => resolve_index_selection(&selection_args)?,
55 PluginSelectionMode::ExistingWrite => resolve_update_selection(root, &selection_args)?,
56 PluginSelectionMode::ReadOnly => resolve_read_only_selection(root, &selection_args)?,
57 PluginSelectionMode::Diff => {
58 if resolve_explicit_selection(&selection_args)?.is_some() {
59 bail!(
60 "plugin-selection overrides are not allowed for `sqry diff`; rebuild or update the indexed workspace with the desired plugins first"
61 );
62 }
63 resolve_read_only_selection(root, &PluginSelectionArgs::default())?
64 }
65 };
66 let plugin_manager = create_manager_from_selection(&selection)?;
67 let persisted_selection = Some(PluginSelectionManifest {
68 active_plugin_ids: selection.active_plugin_ids,
69 high_cost_mode: selection.high_cost_mode,
70 });
71
72 Ok(ResolvedPluginManager {
73 plugin_manager,
74 persisted_selection,
75 })
76}
77
78pub fn resolve_index_selection(args: &PluginSelectionArgs) -> Result<ResolvedPluginSelection> {
84 resolve_explicit_selection(args)?.map_or_else(resolve_default_fast_path_selection, Ok)
85}
86
87pub fn resolve_update_selection(
93 root: &Path,
94 args: &PluginSelectionArgs,
95) -> Result<ResolvedPluginSelection> {
96 resolve_explicit_selection(args)?
97 .map_or_else(|| resolve_persisted_or_legacy_selection(root), Ok)
98}
99
100fn resolve_default_fast_path_selection() -> Result<ResolvedPluginSelection> {
101 resolved_from_registry_config(&PluginSelectionConfig::default())
102}
103
104fn resolve_read_only_selection(
105 root: &Path,
106 args: &PluginSelectionArgs,
107) -> Result<ResolvedPluginSelection> {
108 let storage = GraphStorage::new(root);
109 if storage.exists() {
110 let persisted_selection = resolve_persisted_or_legacy_selection(root)?;
111 if let Some(explicit_selection) = resolve_explicit_selection(args)?
112 && explicit_selection.active_plugin_ids != persisted_selection.active_plugin_ids
113 {
114 bail!(
115 "plugin-selection overrides conflict with the persisted index selection; check CLI flags and SQRY_* plugin-selection environment variables, then rebuild the index if you want a new plugin set"
116 );
117 }
118 return Ok(persisted_selection);
119 }
120
121 resolve_explicit_selection(args)?.map_or_else(resolve_default_fast_path_selection, Ok)
122}
123
124fn resolve_persisted_or_legacy_selection(root: &Path) -> Result<ResolvedPluginSelection> {
125 let storage = GraphStorage::new(root);
126 let manifest = storage.load_manifest().with_context(|| {
127 format!(
128 "failed to load manifest for plugin selection at {}",
129 storage.manifest_path().display()
130 )
131 })?;
132
133 if let Some(plugin_selection) = manifest.plugin_selection {
134 return Ok(ResolvedPluginSelection {
135 active_plugin_ids: plugin_selection.active_plugin_ids,
136 high_cost_mode: plugin_selection.high_cost_mode,
137 });
138 }
139
140 Ok(ResolvedPluginSelection {
141 active_plugin_ids: sqry_plugin_registry::builtin_plugin_ids(),
142 high_cost_mode: Some(HighCostMode::IncludeAll.as_str().to_string()),
143 })
144}
145
146fn create_manager_from_selection(selection: &ResolvedPluginSelection) -> Result<PluginManager> {
147 create_plugin_manager_for_plugin_ids(&selection.active_plugin_ids)
148 .with_context(|| "failed to create plugin manager from resolved selection".to_string())
149}
150
151fn resolve_explicit_selection(
152 args: &PluginSelectionArgs,
153) -> Result<Option<ResolvedPluginSelection>> {
154 let env_selection = EnvPluginSelection::from_env()?;
155 if !args.include_high_cost
156 && !args.exclude_high_cost
157 && args.enable_plugins.is_empty()
158 && args.disable_plugins.is_empty()
159 && !env_selection.is_explicit()
160 {
161 return Ok(None);
162 }
163
164 let high_cost_mode = if args.include_high_cost {
165 HighCostMode::IncludeAll
166 } else if args.exclude_high_cost {
167 HighCostMode::ExcludeAll
168 } else if env_selection.include_high_cost {
169 HighCostMode::IncludeAll
170 } else if env_selection.exclude_high_cost {
171 HighCostMode::ExcludeAll
172 } else {
173 HighCostMode::FastPathDefault
174 };
175
176 let mut enable_plugins = env_selection.enable_plugins;
177 enable_plugins.extend(args.enable_plugins.clone());
178 let mut disable_plugins = env_selection.disable_plugins;
179 disable_plugins.extend(args.disable_plugins.clone());
180
181 resolved_from_registry_config(&PluginSelectionConfig {
182 high_cost_mode,
183 enable_plugins: enable_plugins.into_iter().collect::<BTreeSet<_>>(),
184 disable_plugins: disable_plugins.into_iter().collect::<BTreeSet<_>>(),
185 })
186 .map(Some)
187}
188
189fn resolved_from_registry_config(
190 config: &PluginSelectionConfig,
191) -> Result<ResolvedPluginSelection> {
192 let PluginSelectionResolution {
193 high_cost_mode,
194 active_plugin_ids,
195 } = resolve_registry_plugin_selection(config)
196 .with_context(|| "failed to resolve plugin selection configuration".to_string())?;
197
198 Ok(ResolvedPluginSelection {
199 active_plugin_ids,
200 high_cost_mode: Some(high_cost_mode.as_str().to_string()),
201 })
202}
203
204#[derive(Debug, Clone, Default, PartialEq, Eq)]
205struct EnvPluginSelection {
206 include_high_cost: bool,
207 exclude_high_cost: bool,
208 enable_plugins: Vec<String>,
209 disable_plugins: Vec<String>,
210}
211
212impl EnvPluginSelection {
213 fn from_env() -> Result<Self> {
214 let include_high_cost = parse_env_bool("SQRY_INCLUDE_HIGH_COST")?;
215 let exclude_high_cost = parse_env_bool("SQRY_EXCLUDE_HIGH_COST")?;
216 if include_high_cost && exclude_high_cost {
217 bail!("SQRY_INCLUDE_HIGH_COST and SQRY_EXCLUDE_HIGH_COST cannot both be enabled");
218 }
219
220 Ok(Self {
221 include_high_cost,
222 exclude_high_cost,
223 enable_plugins: parse_env_plugin_list("SQRY_ENABLE_PLUGINS"),
224 disable_plugins: parse_env_plugin_list("SQRY_DISABLE_PLUGINS"),
225 })
226 }
227
228 fn is_explicit(&self) -> bool {
229 self.include_high_cost
230 || self.exclude_high_cost
231 || !self.enable_plugins.is_empty()
232 || !self.disable_plugins.is_empty()
233 }
234}
235
236fn parse_env_bool(name: &str) -> Result<bool> {
237 let Ok(raw) = std::env::var(name) else {
238 return Ok(false);
239 };
240
241 match raw.trim().to_ascii_lowercase().as_str() {
242 "" | "0" | "false" | "no" | "off" => Ok(false),
243 "1" | "true" | "yes" | "on" => Ok(true),
244 _ => bail!("{name} must be one of 0/1/false/true/no/yes/off/on"),
245 }
246}
247
248fn parse_env_plugin_list(name: &str) -> Vec<String> {
249 let Ok(raw) = std::env::var(name) else {
250 return Vec::new();
251 };
252
253 raw.split(',')
254 .map(str::trim)
255 .filter(|value| !value.is_empty())
256 .map(ToString::to_string)
257 .collect()
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use crate::large_stack_test;
264 use clap::Parser;
265 use serial_test::serial;
266 use sqry_core::graph::unified::persistence::{BuildProvenance, Manifest};
267 use tempfile::TempDir;
268
269 #[test]
270 fn test_create_plugin_manager_delegates() {
271 let pm = create_plugin_manager();
272 assert!(pm.plugin_for_extension("rs").is_some());
273 }
274
275 #[test]
276 #[serial]
277 fn test_default_index_selection_excludes_json() {
278 with_cleared_plugin_env(|| {
279 let selection = resolve_index_selection(&PluginSelectionArgs::default())
280 .expect("selection resolves");
281 assert!(!selection.active_plugin_ids.iter().any(|id| id == "json"));
282 });
283 }
284
285 large_stack_test! {
286 #[test]
287 #[serial]
288 fn test_cli_and_env_plugin_lists_are_merged() {
289 with_cleared_plugin_env(|| {
290 unsafe {
291 std::env::set_var("SQRY_ENABLE_PLUGINS", "json");
292 std::env::set_var("SQRY_DISABLE_PLUGINS", "shell");
293 }
294
295 let cli = Cli::parse_from([
296 "sqry",
297 "index",
298 "--enable-plugin",
299 "rust",
300 "--disable-plugin",
301 "sql",
302 ]);
303
304 let resolved =
305 resolve_plugin_selection(&cli, Path::new("."), PluginSelectionMode::FreshWrite)
306 .expect("selection should resolve");
307 let plugin_ids = resolved
308 .persisted_selection
309 .expect("persisted selection should exist")
310 .active_plugin_ids;
311
312 assert!(plugin_ids.iter().any(|id| id == "json"));
313 assert!(plugin_ids.iter().any(|id| id == "rust"));
314 assert!(!plugin_ids.iter().any(|id| id == "shell"));
315 assert!(!plugin_ids.iter().any(|id| id == "sql"));
316 });
317 }
318 }
319
320 large_stack_test! {
321 #[test]
322 #[serial]
323 fn test_read_only_selection_accepts_matching_explicit_selection() {
324 with_cleared_plugin_env(|| {
325 let temp_dir = TempDir::new().expect("temp dir should be created");
326 let selection = resolve_index_selection(&PluginSelectionArgs::default())
327 .expect("selection resolves");
328 write_manifest_with_selection(temp_dir.path(), &selection);
329
330 let cli = Cli::parse_from([
331 "sqry",
332 "query",
333 "kind:function",
334 temp_dir.path().to_str().expect("temp path should be utf-8"),
335 "--disable-plugin",
336 "json",
337 ]);
338
339 let resolved =
340 resolve_plugin_selection(&cli, temp_dir.path(), PluginSelectionMode::ReadOnly)
341 .expect("matching explicit selection should be accepted");
342 assert_eq!(
343 resolved
344 .persisted_selection
345 .expect("persisted selection should be present")
346 .active_plugin_ids,
347 selection.active_plugin_ids
348 );
349 });
350 }
351 }
352
353 large_stack_test! {
354 #[test]
355 #[serial]
356 fn test_read_only_selection_rejects_conflicting_explicit_selection() {
357 with_cleared_plugin_env(|| {
358 let temp_dir = TempDir::new().expect("temp dir should be created");
359 let selection = resolve_index_selection(&PluginSelectionArgs::default())
360 .expect("selection resolves");
361 write_manifest_with_selection(temp_dir.path(), &selection);
362
363 let cli = Cli::parse_from([
364 "sqry",
365 "query",
366 "kind:function",
367 temp_dir.path().to_str().expect("temp path should be utf-8"),
368 "--include-high-cost",
369 ]);
370
371 let result =
372 resolve_plugin_selection(&cli, temp_dir.path(), PluginSelectionMode::ReadOnly);
373 assert!(
374 result.is_err(),
375 "conflicting explicit selection should be rejected"
376 );
377 let err = match result {
378 Ok(_) => unreachable!("conflicting explicit selection should be rejected"),
379 Err(err) => err,
380 };
381 assert!(err.to_string().contains("conflict"));
382 });
383 }
384 }
385
386 fn write_manifest_with_selection(root: &Path, selection: &ResolvedPluginSelection) {
387 let storage = GraphStorage::new(root);
388 std::fs::create_dir_all(storage.graph_dir()).expect("graph dir should exist");
389 Manifest::new(
390 root.to_string_lossy().to_string(),
391 1,
392 1,
393 "fixture-sha256",
394 BuildProvenance::new("test", "test"),
395 )
396 .with_plugin_selection(Some(PluginSelectionManifest {
397 active_plugin_ids: selection.active_plugin_ids.clone(),
398 high_cost_mode: selection.high_cost_mode.clone(),
399 }))
400 .save(storage.manifest_path())
401 .expect("manifest should be written");
402 }
403
404 fn with_cleared_plugin_env(test_fn: impl FnOnce()) {
405 let saved_values = [
406 (
407 "SQRY_INCLUDE_HIGH_COST",
408 std::env::var("SQRY_INCLUDE_HIGH_COST").ok(),
409 ),
410 (
411 "SQRY_EXCLUDE_HIGH_COST",
412 std::env::var("SQRY_EXCLUDE_HIGH_COST").ok(),
413 ),
414 (
415 "SQRY_ENABLE_PLUGINS",
416 std::env::var("SQRY_ENABLE_PLUGINS").ok(),
417 ),
418 (
419 "SQRY_DISABLE_PLUGINS",
420 std::env::var("SQRY_DISABLE_PLUGINS").ok(),
421 ),
422 ];
423
424 for (key, _) in &saved_values {
425 unsafe {
426 std::env::remove_var(key);
427 }
428 }
429
430 test_fn();
431
432 for (key, value) in saved_values {
433 unsafe {
434 if let Some(value) = value {
435 std::env::set_var(key, value);
436 } else {
437 std::env::remove_var(key);
438 }
439 }
440 }
441 }
442}