gravel_provider_custom/
lib.rs

1//! Returns a static set of hits based on configuration, then runs configurable commands when they are selected.
2//!
3//! Example configuration:
4//! ```yml
5//! - plugin: custom
6//!   config:
7//!     hits:
8//!       - title: say hello
9//!         action: "echo hello from bash"
10//!
11//!       - title: what do you see?
12//!         action: ["ls", "-lAh"]
13//!         post_action: nothing
14//!
15//!       - title: this one is sticky
16//!         subtitle: and it won't leave you alone
17//!         override_score: 4294967295
18//!         action:
19//!           shell: zsh
20//!           command: "where zsh"
21//!         post_action: refresh
22//! ```
23//!
24//! ### Required Fields
25//!
26//! #### title
27//! This text is prominently displayed in the UI.
28//!
29//! #### action
30//! Defines the command run when the hit is selected.  
31//! This one has three forms:
32//! - System Shell  
33//!
34//!   This runs the command as one string in the system shell, either `sh` on linux or `cmd` on windows.
35//!   ```yml
36//!   action: "echo this is run in 'sh' or 'cmd'"
37//!   ```
38//!   <br>
39//!
40//! - Shell  
41//!
42//!   This assumes the executable accepts arguments like `bash -c "command here"`, and therefore won't work well with powershell for example.  
43//!   In those cases, use the raw command form below.
44//!   ```yml
45//!   action:
46//!     shell: zsh
47//!     command: "echo this is run in 'zsh'; where zsh"
48//!   ```
49//!   <br>
50//!
51//! - Raw Command  
52//!
53//!   Runs the first parameter as an executable, passing the rest as args. It _does not_ use a shell for this, the executable is executed directly.  
54//!   The first argument can be an absolute or relative path (to gravel's working directory), or the name of a binary in the PATH.
55//!   ```yml
56//!   action: ["ls", "-lAh", "/some directory with spaces/abc"]
57//!   ```
58//!
59//! ### Optional Fields
60//!
61//! #### subtitle
62//! This text is displayed next to the title.  
63//! It will default to being empty.
64//!
65//! #### override_score
66//! Allows you to skip the scoring process for this hit and assign it a score directly.  
67//! Must be an integer between 0 and 4294967295 (32bit unsigned integer).
68//! Defaults to `null`, using the normal scoring process.
69//!
70//! #### secondary_action
71//! This is defined identically to the regular action above, but triggered on the secondary action instead.
72//!
73//! #### wait
74//! Either `true` or `false`, this specifies if the provider should wait until the action is completed.  
75//! Be careful, setting this to `true` on a long-running command will hang the UI!
76//!
77//! #### post_action
78//! Defines what should be done after the action.  
79//! Valid values are:
80//! - `nothing`
81//! - `hide`, this hides the frontend
82//! - `refresh`, this refreshes the frontend, running the current query again
83
84use gravel_ffi::prelude::*;
85use nonempty::NonEmpty;
86use serde::Deserialize;
87use std::{ffi::OsStr, io, process::Command};
88
89struct CustomProvider {
90	hits: StaticHitCache,
91}
92
93#[gravel_provider("custom")]
94impl Provider for CustomProvider {
95	fn new(config: &PluginConfigAdapter<'_>) -> Self {
96		let hits = config.get::<Config>("").hits;
97		log::trace!("initializing custom provider with {} hits", hits.len());
98
99		Self {
100			hits: StaticHitCache::new(hits.into_iter().map(into_hit)),
101		}
102	}
103
104	fn query(&self, _query: &str) -> ProviderResult {
105		ProviderResult::from_cached(self.hits.get())
106	}
107}
108
109fn into_hit(config: HitConfig) -> SimpleHit {
110	let subtitle = config.subtitle.unwrap_or_default();
111	let wait = config.wait.unwrap_or(true);
112
113	let mut hit = SimpleHit::new(config.title, subtitle, move |h, context| {
114		run_action(&config.action, wait, h);
115		run_post_action(context, config.post_action);
116	})
117	.with_score(config.override_score);
118
119	if let Some(secondary_action) = config.secondary_action {
120		hit = hit.with_secondary(move |hit, context| {
121			run_action(&secondary_action, wait, hit);
122			run_post_action(context, config.post_action);
123		});
124	}
125
126	hit
127}
128
129fn run_action(action: &Action, wait: bool, hit: &SimpleHit) {
130	log::debug!("running custom hit '{}'", hit.title());
131
132	let command = match action {
133		#[cfg(unix)]
134		Action::SystemShell(arg) => command("sh", ["-c", arg]),
135
136		#[cfg(windows)]
137		Action::SystemShell(arg) => command("cmd", ["/c", arg]),
138
139		Action::Shell { shell, command: arg } => command(shell, ["-c", arg]),
140		Action::Command(args) => command(args.first(), args.iter().skip(1)),
141	};
142
143	if let Err(e) = run_command(command, wait) {
144		log::error!("unable to run custom hit '{}': {e}", hit.title());
145	}
146}
147
148fn run_command(mut command: Command, wait: bool) -> io::Result<()> {
149	command.spawn().and_then(|mut p| {
150		if wait {
151			p.wait()?;
152		}
153
154		Ok(())
155	})
156}
157
158fn command<I, P>(executable: impl AsRef<OsStr>, args: I) -> Command
159where
160	I: IntoIterator<Item = P>,
161	P: AsRef<OsStr>,
162{
163	let mut command = Command::new(executable);
164	command.args(args);
165
166	command
167}
168
169fn run_post_action(context: RefDynHitActionContext<'_>, action: PostAction) {
170	match action {
171		PostAction::Nothing => (),
172		PostAction::Hide => context.hide_frontend(),
173		PostAction::Refresh => context.refresh_frontend(),
174	}
175}
176
177#[derive(Deserialize, Debug)]
178struct Config {
179	pub hits: Vec<HitConfig>,
180}
181
182#[derive(Deserialize, Debug)]
183struct HitConfig {
184	pub title: String,
185	pub subtitle: Option<String>,
186	pub override_score: Option<u32>,
187	pub wait: Option<bool>,
188	pub action: Action,
189	pub secondary_action: Option<Action>,
190
191	#[serde(default)]
192	pub post_action: PostAction,
193}
194
195#[derive(Deserialize, Debug)]
196#[serde(untagged)]
197#[serde(expecting = "data did not match any known action format")]
198enum Action {
199	SystemShell(String),
200	Shell { shell: String, command: String },
201	Command(NonEmpty<String>),
202}
203
204#[derive(Deserialize, Debug, Clone, Copy)]
205#[serde(rename_all = "snake_case")]
206#[derive(Default)]
207enum PostAction {
208	Nothing,
209	#[default]
210	Hide,
211	Refresh,
212}