television_previewers/previewers/
command.rs1use crate::previewers::cache::PreviewCache;
2use crate::previewers::{Preview, PreviewContent};
3use lazy_static::lazy_static;
4use parking_lot::Mutex;
5use regex::Regex;
6use rustc_hash::FxHashSet;
7use std::sync::atomic::{AtomicU8, Ordering};
8use std::sync::Arc;
9use television_channels::entry::{Entry, PreviewCommand};
10use television_utils::command::shell_command;
11use tracing::debug;
12
13#[allow(dead_code)]
14#[derive(Debug, Default)]
15pub struct CommandPreviewer {
16 cache: Arc<Mutex<PreviewCache>>,
17 config: CommandPreviewerConfig,
18 concurrent_preview_tasks: Arc<AtomicU8>,
19 in_flight_previews: Arc<Mutex<FxHashSet<String>>>,
20}
21
22#[allow(dead_code)]
23#[derive(Debug, Clone)]
24pub struct CommandPreviewerConfig {
25 delimiter: String,
26}
27
28const DEFAULT_DELIMITER: &str = " ";
29
30impl Default for CommandPreviewerConfig {
31 fn default() -> Self {
32 CommandPreviewerConfig {
33 delimiter: String::from(DEFAULT_DELIMITER),
34 }
35 }
36}
37
38impl CommandPreviewerConfig {
39 pub fn new(delimiter: &str) -> Self {
40 CommandPreviewerConfig {
41 delimiter: String::from(delimiter),
42 }
43 }
44}
45
46const MAX_CONCURRENT_PREVIEW_TASKS: u8 = 3;
47
48impl CommandPreviewer {
49 pub fn new(config: Option<CommandPreviewerConfig>) -> Self {
50 let config = config.unwrap_or_default();
51 CommandPreviewer {
52 cache: Arc::new(Mutex::new(PreviewCache::default())),
53 config,
54 concurrent_preview_tasks: Arc::new(AtomicU8::new(0)),
55 in_flight_previews: Arc::new(Mutex::new(FxHashSet::default())),
56 }
57 }
58
59 pub fn cached(&self, entry: &Entry) -> Option<Arc<Preview>> {
60 self.cache.lock().get(&entry.name)
61 }
62
63 pub fn preview(
64 &mut self,
65 entry: &Entry,
66 command: &PreviewCommand,
67 ) -> Option<Arc<Preview>> {
68 if let Some(preview) = self.cached(entry) {
69 Some(preview)
70 } else {
71 debug!("Preview cache miss for {:?}", entry.name);
73 self.handle_preview_request(entry, command);
74 None
75 }
76 }
77
78 pub fn handle_preview_request(
79 &mut self,
80 entry: &Entry,
81 command: &PreviewCommand,
82 ) {
83 if self.in_flight_previews.lock().contains(&entry.name) {
84 debug!("Preview already in flight for {:?}", entry.name);
85 return;
86 }
87
88 if self.concurrent_preview_tasks.load(Ordering::Relaxed)
89 < MAX_CONCURRENT_PREVIEW_TASKS
90 {
91 self.in_flight_previews.lock().insert(entry.name.clone());
92 self.concurrent_preview_tasks
93 .fetch_add(1, Ordering::Relaxed);
94 let cache = self.cache.clone();
95 let entry_c = entry.clone();
96 let concurrent_tasks = self.concurrent_preview_tasks.clone();
97 let command = command.clone();
98 let in_flight_previews = self.in_flight_previews.clone();
99 tokio::spawn(async move {
100 try_preview(
101 &command,
102 &entry_c,
103 &cache,
104 &concurrent_tasks,
105 &in_flight_previews,
106 );
107 });
108 } else {
109 debug!(
110 "Too many concurrent preview tasks, skipping {:?}",
111 entry.name
112 );
113 }
114 }
115}
116
117lazy_static! {
118 static ref COMMAND_PLACEHOLDER_REGEX: Regex =
119 Regex::new(r"\{(\d+)\}").unwrap();
120}
121
122pub fn format_command(command: &PreviewCommand, entry: &Entry) -> String {
139 let parts = entry.name.split(&command.delimiter).collect::<Vec<&str>>();
140 debug!("Parts: {:?}", parts);
141
142 let mut formatted_command = command
143 .command
144 .replace("{}", format!("'{}'", entry.name).as_str());
145
146 formatted_command = COMMAND_PLACEHOLDER_REGEX
147 .replace_all(&formatted_command, |caps: ®ex::Captures| {
148 let index =
149 caps.get(1).unwrap().as_str().parse::<usize>().unwrap();
150 format!("'{}'", parts[index])
151 })
152 .to_string();
153
154 formatted_command
155}
156
157pub fn try_preview(
158 command: &PreviewCommand,
159 entry: &Entry,
160 cache: &Arc<Mutex<PreviewCache>>,
161 concurrent_tasks: &Arc<AtomicU8>,
162 in_flight_previews: &Arc<Mutex<FxHashSet<String>>>,
163) {
164 debug!("Computing preview for {:?}", entry.name);
165 let command = format_command(command, entry);
166 debug!("Formatted preview command: {:?}", command);
167
168 let child = shell_command()
169 .arg(&command)
170 .output()
171 .expect("failed to execute process");
172
173 if child.status.success() {
174 let content = String::from_utf8_lossy(&child.stdout);
175 let preview = Arc::new(Preview::new(
176 entry.name.clone(),
177 PreviewContent::AnsiText(content.to_string()),
178 None,
179 None,
180 u16::try_from(content.lines().count()).unwrap_or(u16::MAX),
181 ));
182
183 cache.lock().insert(entry.name.clone(), &preview);
184 } else {
185 let content = String::from_utf8_lossy(&child.stderr);
186 let preview = Arc::new(Preview::new(
187 entry.name.clone(),
188 PreviewContent::AnsiText(content.to_string()),
189 None,
190 None,
191 u16::try_from(content.lines().count()).unwrap_or(u16::MAX),
192 ));
193 cache.lock().insert(entry.name.clone(), &preview);
194 }
195
196 concurrent_tasks.fetch_sub(1, Ordering::Relaxed);
197 in_flight_previews.lock().remove(&entry.name);
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use television_channels::entry::{Entry, PreviewType};
204
205 #[test]
206 fn test_format_command() {
207 let command = PreviewCommand {
208 command: "something {} {2} {0}".to_string(),
209 delimiter: ":".to_string(),
210 };
211 let entry = Entry::new(
212 "an:entry:to:preview".to_string(),
213 PreviewType::Command(command.clone()),
214 );
215 let formatted_command = format_command(&command, &entry);
216
217 assert_eq!(
218 formatted_command,
219 "something 'an:entry:to:preview' 'to' 'an'"
220 );
221 }
222
223 #[test]
224 fn test_format_command_no_placeholders() {
225 let command = PreviewCommand {
226 command: "something".to_string(),
227 delimiter: ":".to_string(),
228 };
229 let entry = Entry::new(
230 "an:entry:to:preview".to_string(),
231 PreviewType::Command(command.clone()),
232 );
233 let formatted_command = format_command(&command, &entry);
234
235 assert_eq!(formatted_command, "something");
236 }
237
238 #[test]
239 fn test_format_command_with_global_placeholder_only() {
240 let command = PreviewCommand {
241 command: "something {}".to_string(),
242 delimiter: ":".to_string(),
243 };
244 let entry = Entry::new(
245 "an:entry:to:preview".to_string(),
246 PreviewType::Command(command.clone()),
247 );
248 let formatted_command = format_command(&command, &entry);
249
250 assert_eq!(formatted_command, "something 'an:entry:to:preview'");
251 }
252
253 #[test]
254 fn test_format_command_with_positional_placeholders_only() {
255 let command = PreviewCommand {
256 command: "something {0} -t {2}".to_string(),
257 delimiter: ":".to_string(),
258 };
259 let entry = Entry::new(
260 "an:entry:to:preview".to_string(),
261 PreviewType::Command(command.clone()),
262 );
263 let formatted_command = format_command(&command, &entry);
264
265 assert_eq!(formatted_command, "something 'an' -t 'to'");
266 }
267}