television_previewers/previewers/
command.rs

1use 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            // preview is not in cache, spawn a task to compute the preview
72            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
122/// Format the command with the entry name and provided placeholders
123///
124/// # Example
125/// ```
126/// use television_channels::entry::{PreviewCommand, PreviewType, Entry};
127/// use television_previewers::previewers::command::format_command;
128///
129/// let command = PreviewCommand {
130///     command: "something {} {2} {0}".to_string(),
131///     delimiter: ":".to_string(),
132/// };
133/// let entry = Entry::new("a:given:entry:to:preview".to_string(), PreviewType::Command(command.clone()));
134/// let formatted_command = format_command(&command, &entry);
135///
136/// assert_eq!(formatted_command, "something 'a:given:entry:to:preview' 'entry' 'a'");
137/// ```
138pub 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: &regex::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}