Skip to main content

sqry_cli/
plugin_defaults.rs

1//! Plugin-selection helpers for CLI entry points.
2
3use 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
41/// Resolve the effective plugin manager and persisted selection metadata.
42///
43/// # Errors
44///
45/// Returns an error if plugin overrides are invalid, a persisted manifest cannot
46/// be loaded safely, or a read-only command would reinterpret an indexed workspace.
47pub 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
78/// Resolve the plugin selection for `sqry index`.
79///
80/// # Errors
81///
82/// Returns an error if CLI or environment overrides reference unknown plugins.
83pub fn resolve_index_selection(args: &PluginSelectionArgs) -> Result<ResolvedPluginSelection> {
84    resolve_explicit_selection(args)?.map_or_else(resolve_default_fast_path_selection, Ok)
85}
86
87/// Resolve the plugin selection for `sqry update`.
88///
89/// # Errors
90///
91/// Returns an error if manifest loading fails or plugin overrides are invalid.
92pub 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}