autopulse_service/settings/targets/
command.rs1use 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#[derive(Serialize, Clone, Deserialize)]
12pub struct Command {
13 pub path: Option<String>,
17 pub timeout: Option<u64>,
21 pub raw: Option<String>,
25 pub rewrite: Option<Rewrite>,
27 #[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}