Skip to main content

hematite/agent/
scheduler.rs

1const TASK_NAME: &str = "Hematite Health Check";
2const TASK_SWEEP_NAME: &str = "Hematite Maintenance Sweep";
3const TASK_TIMELINE_NAME: &str = "Hematite Timeline Capture";
4const TASK_ALERT_NAME: &str = "Hematite Alert Rules";
5
6pub fn register_alert_task(cadence: &str, exe_path: &str) -> Result<String, String> {
7    #[cfg(not(target_os = "windows"))]
8    {
9        let _ = (cadence, exe_path);
10        return Err("Scheduled tasks require Windows (schtasks.exe).\n\
11             On Linux/macOS use cron instead:\n\
12               0 * * * * hematite --alert-rule-run"
13            .into());
14    }
15
16    #[cfg(target_os = "windows")]
17    {
18        let task_run = format!("\"{}\" --alert-rule-run", exe_path);
19        let (schedule_type, extra_args, label): (&str, &[&str], &str) = match cadence {
20            "daily" => ("daily", &[], "daily at 03:00"),
21            _ => ("hourly", &[], "hourly"),
22        };
23        let mut args: Vec<String> = vec![
24            "/create".into(),
25            "/tn".into(),
26            TASK_ALERT_NAME.into(),
27            "/tr".into(),
28            task_run.clone(),
29            "/sc".into(),
30            schedule_type.into(),
31            "/st".into(),
32            "03:00".into(),
33        ];
34        for a in extra_args {
35            args.push(a.to_string());
36        }
37        args.push("/f".into());
38        let out = std::process::Command::new("schtasks")
39            .args(&args)
40            .output()
41            .map_err(|e| format!("Failed to run schtasks: {}", e))?;
42        if out.status.success() {
43            Ok(format!(
44                "Task \"{}\" registered — runs {}.\n\
45                 Action: {}\n\
46                 Run `hematite --alert-rule-run --schedule status` to confirm.",
47                TASK_ALERT_NAME, label, task_run
48            ))
49        } else {
50            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
51            let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
52            Err(if !stderr.is_empty() { stderr } else { stdout })
53        }
54    }
55}
56
57pub fn remove_alert_task() -> Result<String, String> {
58    #[cfg(not(target_os = "windows"))]
59    return Err("Scheduled tasks require Windows.".into());
60
61    #[cfg(target_os = "windows")]
62    {
63        let out = std::process::Command::new("schtasks")
64            .args(["/delete", "/tn", TASK_ALERT_NAME, "/f"])
65            .output()
66            .map_err(|e| format!("Failed to run schtasks: {}", e))?;
67        if out.status.success() {
68            Ok(format!("Task \"{}\" removed.", TASK_ALERT_NAME))
69        } else {
70            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
71            Err(if !stderr.is_empty() {
72                stderr
73            } else {
74                format!("Task \"{}\" not found.", TASK_ALERT_NAME)
75            })
76        }
77    }
78}
79
80pub fn query_alert_task() -> String {
81    #[cfg(not(target_os = "windows"))]
82    return "Scheduled tasks are Windows-only.".to_string();
83
84    #[cfg(target_os = "windows")]
85    {
86        let out = std::process::Command::new("schtasks")
87            .args(["/query", "/tn", TASK_ALERT_NAME, "/fo", "LIST"])
88            .output();
89        match out {
90            Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
91            Ok(o) => {
92                let stderr = String::from_utf8_lossy(&o.stderr).to_ascii_lowercase();
93                if stderr.contains("cannot find") || stderr.contains("does not exist") {
94                    format!("Task \"{}\" is not registered.", TASK_ALERT_NAME)
95                } else {
96                    format!(
97                        "Not registered: {}",
98                        String::from_utf8_lossy(&o.stderr).trim()
99                    )
100                }
101            }
102            Err(e) => format!("Error querying task: {}", e),
103        }
104    }
105}
106
107pub fn register_timeline_task(exe_path: &str) -> Result<String, String> {
108    #[cfg(not(target_os = "windows"))]
109    {
110        let _ = exe_path;
111        return Err("Scheduled tasks require Windows (schtasks.exe).\n\
112             On Linux/macOS use cron instead:\n\
113               0 3 * * * hematite --timeline-capture"
114            .into());
115    }
116
117    #[cfg(target_os = "windows")]
118    {
119        let task_run = format!("\"{}\" --timeline-capture", exe_path);
120        let args: Vec<String> = vec![
121            "/create".into(),
122            "/tn".into(),
123            TASK_TIMELINE_NAME.into(),
124            "/tr".into(),
125            task_run.clone(),
126            "/sc".into(),
127            "daily".into(),
128            "/st".into(),
129            "03:00".into(),
130            "/f".into(),
131        ];
132        let out = std::process::Command::new("schtasks")
133            .args(&args)
134            .output()
135            .map_err(|e| format!("Failed to run schtasks: {}", e))?;
136
137        if out.status.success() {
138            let dir = crate::tools::file_ops::hematite_dir().join("timeline");
139            Ok(format!(
140                "Task \"{}\" registered — runs daily at 03:00.\n\
141                 Action: {}\n\
142                 Timeline entries will save to: {}\n\
143                 Run `hematite --timeline` to view history.",
144                TASK_TIMELINE_NAME,
145                task_run,
146                dir.display()
147            ))
148        } else {
149            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
150            let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
151            Err(if !stderr.is_empty() { stderr } else { stdout })
152        }
153    }
154}
155
156pub fn remove_timeline_task() -> Result<String, String> {
157    #[cfg(not(target_os = "windows"))]
158    return Err("Scheduled tasks require Windows.".into());
159
160    #[cfg(target_os = "windows")]
161    {
162        let out = std::process::Command::new("schtasks")
163            .args(["/delete", "/tn", TASK_TIMELINE_NAME, "/f"])
164            .output()
165            .map_err(|e| format!("Failed to run schtasks: {}", e))?;
166
167        if out.status.success() {
168            Ok(format!("Task \"{}\" removed.", TASK_TIMELINE_NAME))
169        } else {
170            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
171            Err(if !stderr.is_empty() {
172                stderr
173            } else {
174                format!(
175                    "Task \"{}\" not found — nothing to remove.",
176                    TASK_TIMELINE_NAME
177                )
178            })
179        }
180    }
181}
182
183pub fn query_timeline_task() -> String {
184    #[cfg(not(target_os = "windows"))]
185    return "Scheduled tasks are Windows-only.".to_string();
186
187    #[cfg(target_os = "windows")]
188    {
189        let out = std::process::Command::new("schtasks")
190            .args(["/query", "/tn", TASK_TIMELINE_NAME, "/fo", "LIST"])
191            .output();
192
193        match out {
194            Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
195            Ok(o) => {
196                let stderr = String::from_utf8_lossy(&o.stderr).to_ascii_lowercase();
197                if stderr.contains("cannot find") || stderr.contains("does not exist") {
198                    format!("Task \"{}\" is not registered.", TASK_TIMELINE_NAME)
199                } else {
200                    format!(
201                        "Not registered: {}",
202                        String::from_utf8_lossy(&o.stderr).trim()
203                    )
204                }
205            }
206            Err(e) => format!("Error querying task: {}", e),
207        }
208    }
209}
210
211pub fn register_sweep_task(cadence: &str, exe_path: &str) -> Result<String, String> {
212    #[cfg(not(target_os = "windows"))]
213    {
214        let _ = (cadence, exe_path);
215        return Err("Scheduled tasks require Windows (schtasks.exe).\n\
216             On Linux/macOS use cron instead:\n\
217               hematite --fix-all --report-format html"
218            .into());
219    }
220
221    #[cfg(target_os = "windows")]
222    {
223        let task_run = format!("\"{}\" --fix-all --report-format html --quiet", exe_path);
224
225        let (schedule_type, extra_args, label): (&str, &[&str], &str) = match cadence {
226            "daily" => ("daily", &[], "daily at 03:00"),
227            _ => ("weekly", &["/d", "SUN"], "weekly on Sunday at 03:00"),
228        };
229
230        let mut args: Vec<String> = vec![
231            "/create".into(),
232            "/tn".into(),
233            TASK_SWEEP_NAME.into(),
234            "/tr".into(),
235            task_run.clone(),
236            "/sc".into(),
237            schedule_type.into(),
238            "/st".into(),
239            "03:00".into(),
240        ];
241        for a in extra_args {
242            args.push(a.to_string());
243        }
244        args.push("/f".into());
245
246        let out = std::process::Command::new("schtasks")
247            .args(&args)
248            .output()
249            .map_err(|e| format!("Failed to run schtasks: {}", e))?;
250
251        if out.status.success() {
252            let reports_dir = crate::tools::file_ops::hematite_dir().join("reports");
253            Ok(format!(
254                "Task \"{}\" registered — runs {}.\n\
255                 Action: {}\n\
256                 Sweep reports will save to: {}\n\
257                 Run `hematite --fix-all --schedule status` to confirm.",
258                TASK_SWEEP_NAME,
259                label,
260                task_run,
261                reports_dir.display()
262            ))
263        } else {
264            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
265            let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
266            Err(if !stderr.is_empty() { stderr } else { stdout })
267        }
268    }
269}
270
271pub fn remove_sweep_task() -> Result<String, String> {
272    #[cfg(not(target_os = "windows"))]
273    return Err("Scheduled tasks require Windows.".into());
274
275    #[cfg(target_os = "windows")]
276    {
277        let out = std::process::Command::new("schtasks")
278            .args(["/delete", "/tn", TASK_SWEEP_NAME, "/f"])
279            .output()
280            .map_err(|e| format!("Failed to run schtasks: {}", e))?;
281
282        if out.status.success() {
283            Ok(format!("Task \"{}\" removed.", TASK_SWEEP_NAME))
284        } else {
285            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
286            Err(if !stderr.is_empty() {
287                stderr
288            } else {
289                format!(
290                    "Task \"{}\" not found — nothing to remove.",
291                    TASK_SWEEP_NAME
292                )
293            })
294        }
295    }
296}
297
298pub fn query_sweep_task() -> String {
299    #[cfg(not(target_os = "windows"))]
300    return "Scheduled tasks are Windows-only. Use cron for recurring sweeps:\n\
301            hematite --fix-all --report-format html"
302        .to_string();
303
304    #[cfg(target_os = "windows")]
305    {
306        let out = std::process::Command::new("schtasks")
307            .args(["/query", "/tn", TASK_SWEEP_NAME, "/fo", "LIST"])
308            .output();
309
310        match out {
311            Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
312            Ok(o) => {
313                let stderr = String::from_utf8_lossy(&o.stderr).to_ascii_lowercase();
314                if stderr.contains("cannot find") || stderr.contains("does not exist") {
315                    format!("Task \"{}\" is not registered.", TASK_SWEEP_NAME)
316                } else {
317                    format!(
318                        "Not registered: {}",
319                        String::from_utf8_lossy(&o.stderr).trim()
320                    )
321                }
322            }
323            Err(e) => format!("Error querying task: {}", e),
324        }
325    }
326}
327
328pub fn register_scheduled_task(cadence: &str, exe_path: &str) -> Result<String, String> {
329    #[cfg(not(target_os = "windows"))]
330    {
331        let _ = (cadence, exe_path);
332        return Err("Scheduled tasks require Windows (schtasks.exe).\n\
333             On Linux/macOS use cron instead:\n\
334               hematite --triage --report-format html"
335            .into());
336    }
337
338    #[cfg(target_os = "windows")]
339    {
340        let task_run = format!("\"{}\" --triage --report-format html", exe_path);
341
342        let (schedule_type, extra_args, label): (&str, &[&str], &str) = match cadence {
343            "daily" => ("daily", &[], "daily at 08:00"),
344            _ => ("weekly", &["/d", "MON"], "weekly on Monday at 08:00"),
345        };
346
347        let mut args: Vec<String> = vec![
348            "/create".into(),
349            "/tn".into(),
350            TASK_NAME.into(),
351            "/tr".into(),
352            task_run.clone(),
353            "/sc".into(),
354            schedule_type.into(),
355            "/st".into(),
356            "08:00".into(),
357        ];
358        for a in extra_args {
359            args.push(a.to_string());
360        }
361        args.push("/f".into());
362
363        let out = std::process::Command::new("schtasks")
364            .args(&args)
365            .output()
366            .map_err(|e| format!("Failed to run schtasks: {}", e))?;
367
368        if out.status.success() {
369            let reports_dir = crate::tools::file_ops::hematite_dir().join("reports");
370            Ok(format!(
371                "Task \"{}\" registered — runs {}.\n\
372                 Action: {}\n\
373                 Reports will save to: {}\n\
374                 Run `hematite --schedule status` to confirm.",
375                TASK_NAME,
376                label,
377                task_run,
378                reports_dir.display()
379            ))
380        } else {
381            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
382            let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
383            Err(if !stderr.is_empty() { stderr } else { stdout })
384        }
385    }
386}
387
388pub fn remove_scheduled_task() -> Result<String, String> {
389    #[cfg(not(target_os = "windows"))]
390    return Err("Scheduled tasks require Windows.".into());
391
392    #[cfg(target_os = "windows")]
393    {
394        let out = std::process::Command::new("schtasks")
395            .args(["/delete", "/tn", TASK_NAME, "/f"])
396            .output()
397            .map_err(|e| format!("Failed to run schtasks: {}", e))?;
398
399        if out.status.success() {
400            Ok(format!("Task \"{}\" removed.", TASK_NAME))
401        } else {
402            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
403            Err(if !stderr.is_empty() {
404                stderr
405            } else {
406                format!("Task \"{}\" not found — nothing to remove.", TASK_NAME)
407            })
408        }
409    }
410}
411
412pub fn query_scheduled_task() -> String {
413    #[cfg(not(target_os = "windows"))]
414    return "Scheduled tasks are Windows-only. Use cron for recurring triage:\n\
415            hematite --triage --report-format html"
416        .to_string();
417
418    #[cfg(target_os = "windows")]
419    {
420        let out = std::process::Command::new("schtasks")
421            .args(["/query", "/tn", TASK_NAME, "/fo", "LIST"])
422            .output();
423
424        match out {
425            Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
426            Ok(o) => {
427                let stderr = String::from_utf8_lossy(&o.stderr).to_ascii_lowercase();
428                if stderr.contains("cannot find") || stderr.contains("does not exist") {
429                    format!("Task \"{}\" is not registered.", TASK_NAME)
430                } else {
431                    format!(
432                        "Not registered: {}",
433                        String::from_utf8_lossy(&o.stderr).trim()
434                    )
435                }
436            }
437            Err(e) => format!("Error querying task: {}", e),
438        }
439    }
440}