Skip to main content

autopulse_service/settings/targets/
command.rs

1use crate::settings::path_filter::PathFilter;
2use crate::settings::rewrite::Rewrite;
3use crate::settings::targets::TargetProcess;
4use autopulse_database::models::ScanEvent;
5use serde::{Deserialize, Serialize};
6use tracing::{debug, error};
7
8/// Command target
9///
10/// Note: Either `path` or `raw` must be set but not both
11#[derive(Serialize, Clone, Deserialize)]
12pub struct Command {
13    /// Path to the command to run
14    ///
15    /// Example: `/path/to/script.sh`
16    pub path: Option<String>,
17    /// Timeout for the command in seconds (default: 10)
18    ///
19    /// Example: `5`
20    pub timeout: Option<u64>,
21    /// Raw command to run
22    ///
23    /// Example: `echo $FILE_PATH >> list.log`
24    pub raw: Option<String>,
25    /// Rewrite path for the file
26    pub rewrite: Option<Rewrite>,
27    /// Path filter matched against the target-rewritten path.
28    #[serde(default)]
29    pub filter: PathFilter,
30}
31
32impl Command {
33    pub async fn run(&self, ev: &ScanEvent) -> anyhow::Result<()> {
34        if self.path.is_some() && self.raw.is_some() {
35            return Err(anyhow::anyhow!("command cannot have both path and raw"));
36        }
37
38        if self.path.is_none() && self.raw.is_none() {
39            return Err(anyhow::anyhow!("command requires either path or raw"));
40        }
41
42        let ev_path = ev.get_path(&self.rewrite);
43
44        if let Some(path) = self.path.clone() {
45            let output = tokio::process::Command::new(path.clone())
46                .arg(&ev_path)
47                .output();
48
49            let timeout = self.timeout.unwrap_or(10);
50
51            let output = tokio::time::timeout(std::time::Duration::from_secs(timeout), output)
52                .await
53                .map_err(|_| anyhow::anyhow!("command timed out"))??;
54
55            debug!("command output: {:?}", output);
56
57            if !output.status.success() {
58                return Err(anyhow::anyhow!(
59                    "command failed with status: {}",
60                    output.status
61                ));
62            }
63        }
64
65        if let Some(raw) = self.raw.clone() {
66            let output = tokio::process::Command::new("sh")
67                .env("FILE_PATH", &ev_path)
68                .arg("-c")
69                .arg(&raw)
70                .output();
71
72            let timeout = self.timeout.unwrap_or(10);
73
74            let output = tokio::time::timeout(std::time::Duration::from_secs(timeout), output)
75                .await
76                .map_err(|_| anyhow::anyhow!("command timed out"))??;
77
78            if !output.status.success() {
79                return Err(anyhow::anyhow!(
80                    "command failed with status: {}",
81                    output.status
82                ));
83            }
84
85            debug!("command output: {:?}", output);
86        }
87
88        Ok(())
89    }
90}
91
92impl TargetProcess for Command {
93    async fn process(&self, evs: &[&ScanEvent]) -> anyhow::Result<Vec<String>> {
94        let mut succeeded = Vec::new();
95
96        for ev in evs {
97            if let Err(e) = self.run(ev).await {
98                error!("failed to process '{}': {}", ev.get_path(&self.rewrite), e);
99            } else {
100                succeeded.push(ev.id.clone());
101            }
102        }
103
104        Ok(succeeded)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::Command;
111    use autopulse_database::models::ScanEvent;
112
113    fn scan_event() -> ScanEvent {
114        let now = chrono::Utc::now().naive_utc();
115
116        ScanEvent {
117            id: "event-id".to_string(),
118            event_source: "manual".to_string(),
119            event_timestamp: now,
120            file_path: "/media/movie.mkv".to_string(),
121            file_hash: None,
122            process_status: "pending".to_string(),
123            found_status: "found".to_string(),
124            failed_times: 0,
125            next_retry_at: None,
126            targets_hit: String::new(),
127            found_at: None,
128            processed_at: None,
129            created_at: now,
130            updated_at: now,
131            can_process: now,
132        }
133    }
134
135    #[tokio::test]
136    async fn run_rejects_command_without_path_or_raw() {
137        let command = Command {
138            path: None,
139            timeout: None,
140            raw: None,
141            rewrite: None,
142            filter: Default::default(),
143        };
144
145        let err = command
146            .run(&scan_event())
147            .await
148            .expect_err("empty command target should fail");
149
150        assert!(
151            err.to_string()
152                .contains("command requires either path or raw"),
153            "unexpected error: {err}"
154        );
155    }
156}