1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use crate::PolicyKind;
5
6pub const DEFAULT_SCHEDULER_INSTANCE_NAME: &str = "cargo-reclaim";
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum SchedulerPlatform {
10 SystemdUser,
11 Launchd,
12 TaskScheduler,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum SchedulerMode {
17 Observe,
18 Cleanup,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct Schedule {
23 pub hour: u8,
24 pub minute: u8,
25}
26
27impl Schedule {
28 pub fn parse(value: &str) -> Result<Self, SchedulerError> {
29 let Some((hour, minute)) = value.split_once(':') else {
30 return Err(SchedulerError::InvalidSchedule(value.to_string()));
31 };
32 if hour.len() != 2 || minute.len() != 2 {
33 return Err(SchedulerError::InvalidSchedule(value.to_string()));
34 }
35 let hour = hour
36 .parse::<u8>()
37 .map_err(|_| SchedulerError::InvalidSchedule(value.to_string()))?;
38 let minute = minute
39 .parse::<u8>()
40 .map_err(|_| SchedulerError::InvalidSchedule(value.to_string()))?;
41 if hour > 23 || minute > 59 {
42 return Err(SchedulerError::InvalidSchedule(value.to_string()));
43 }
44 Ok(Self { hour, minute })
45 }
46
47 pub fn as_hh_mm(self) -> String {
48 format!("{:02}:{:02}", self.hour, self.minute)
49 }
50}
51
52impl Default for Schedule {
53 fn default() -> Self {
54 Self { hour: 3, minute: 0 }
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct SchedulerRequest {
60 pub platform: SchedulerPlatform,
61 pub instance_name: String,
62 pub config_path: PathBuf,
63 pub cargo_reclaim_bin: PathBuf,
64 pub schedule: Schedule,
65 pub mode: SchedulerMode,
66 pub policy: Option<PolicyKind>,
67 pub allow_unattended_cleanup: bool,
68 pub allow_unattended_high_policy: bool,
69 pub state_dir: Option<PathBuf>,
70 pub log_dir: Option<PathBuf>,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum GeneratedArtifactKind {
75 SystemdService,
76 SystemdTimer,
77 LaunchdPlist,
78 TaskSchedulerXml,
79 RunnerScript,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct GeneratedArtifact {
84 pub kind: GeneratedArtifactKind,
85 pub intended_install_path: PathBuf,
86 pub contents: String,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct SchedulerReport {
91 pub command: &'static str,
92 pub dry_run: bool,
93 pub platform: SchedulerPlatform,
94 pub mode: SchedulerMode,
95 pub schedule: Schedule,
96 pub effective_policy: PolicyKind,
97 pub artifacts: Vec<GeneratedArtifact>,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum SchedulerOperation {
102 Install,
103 Uninstall,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct SchedulerOperationPlan {
108 pub command: &'static str,
109 pub operation: SchedulerOperation,
110 pub dry_run: bool,
111 pub platform: SchedulerPlatform,
112 pub artifacts: Vec<GeneratedArtifact>,
113 pub steps: Vec<SchedulerPlanStep>,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum SchedulerPlanStep {
118 EnsureDir {
119 path: PathBuf,
120 },
121 WriteFile {
122 path: PathBuf,
123 artifact_kind: GeneratedArtifactKind,
124 },
125 SetExecutable {
126 path: PathBuf,
127 },
128 RemoveFile {
129 path: PathBuf,
130 },
131 RunCommand {
132 argv: Vec<String>,
133 },
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct SchedulerExecutionReport {
138 pub command: &'static str,
139 pub operation: SchedulerOperation,
140 pub dry_run: bool,
141 pub platform: SchedulerPlatform,
142 pub artifacts: Vec<GeneratedArtifact>,
143 pub steps: Vec<SchedulerExecutionStep>,
144 pub totals: SchedulerExecutionTotals,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct SchedulerExecutionStep {
149 pub step: SchedulerPlanStep,
150 pub status: SchedulerExecutionStatus,
151 pub message: Option<String>,
152 pub command_output: Option<SchedulerCommandOutput>,
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum SchedulerExecutionStatus {
157 Applied,
158 Skipped,
159 Failed,
160 Blocked,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
164pub struct SchedulerExecutionTotals {
165 pub applied: usize,
166 pub skipped: usize,
167 pub failed: usize,
168 pub blocked: usize,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct SchedulerCommandOutput {
173 pub exit_code: Option<i32>,
174 pub stdout: String,
175 pub stderr: String,
176}
177
178impl SchedulerExecutionReport {
179 pub fn succeeded(&self) -> bool {
180 self.totals.failed == 0 && self.totals.blocked == 0
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub enum SchedulerError {
186 InvalidSchedule(String),
187 InvalidInstanceName(String),
188 CleanupNotAllowed,
189 HighPolicyNotAllowed(PolicyKind),
190}
191
192impl fmt::Display for SchedulerError {
193 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
194 match self {
195 Self::InvalidSchedule(value) => write!(
196 formatter,
197 "invalid scheduler time `{value}`; expected HH:MM in 24-hour time"
198 ),
199 Self::InvalidInstanceName(value) => write!(
200 formatter,
201 "invalid scheduler name `{value}`; use only ASCII letters, digits, '-', '_', or '.', and do not use path separators"
202 ),
203 Self::CleanupNotAllowed => formatter.write_str(
204 "scheduler cleanup preview requires --allow-unattended-cleanup or [scheduler].allow_unattended_cleanup = true",
205 ),
206 Self::HighPolicyNotAllowed(policy) => write!(
207 formatter,
208 "scheduler cleanup with {} policy requires an explicit policy and --allow-unattended-high-policy or [scheduler].allow_unattended_high_policy = true",
209 policy_label(*policy)
210 ),
211 }
212 }
213}
214
215impl std::error::Error for SchedulerError {}
216
217pub fn scheduler_instance_name_from_config(
218 explicit_name: Option<&str>,
219 _config_path: &Path,
220) -> Result<String, SchedulerError> {
221 if let Some(name) = explicit_name {
222 return validate_scheduler_instance_name(name).map(ToOwned::to_owned);
223 }
224
225 Ok(DEFAULT_SCHEDULER_INSTANCE_NAME.to_string())
226}
227
228pub(crate) fn validate_scheduler_instance_name(name: &str) -> Result<&str, SchedulerError> {
229 if name.is_empty()
230 || name == "."
231 || name == ".."
232 || name.contains('/')
233 || name.contains('\\')
234 || !name.chars().all(is_safe_instance_character)
235 {
236 return Err(SchedulerError::InvalidInstanceName(name.to_string()));
237 }
238 Ok(name)
239}
240
241fn is_safe_instance_character(character: char) -> bool {
242 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
243}
244
245pub fn policy_label(policy: PolicyKind) -> &'static str {
246 match policy {
247 PolicyKind::Observe => "observe",
248 PolicyKind::Conservative => "conservative",
249 PolicyKind::Balanced => "balanced",
250 PolicyKind::Aggressive => "aggressive",
251 PolicyKind::Custom => "custom",
252 }
253}
254
255pub fn platform_label(platform: SchedulerPlatform) -> &'static str {
256 match platform {
257 SchedulerPlatform::SystemdUser => "systemd-user",
258 SchedulerPlatform::Launchd => "launchd",
259 SchedulerPlatform::TaskScheduler => "task-scheduler",
260 }
261}
262
263pub fn mode_label(mode: SchedulerMode) -> &'static str {
264 match mode {
265 SchedulerMode::Observe => "observe",
266 SchedulerMode::Cleanup => "cleanup",
267 }
268}
269
270pub fn operation_label(operation: SchedulerOperation) -> &'static str {
271 match operation {
272 SchedulerOperation::Install => "install",
273 SchedulerOperation::Uninstall => "uninstall",
274 }
275}
276
277pub fn execution_status_label(status: SchedulerExecutionStatus) -> &'static str {
278 match status {
279 SchedulerExecutionStatus::Applied => "applied",
280 SchedulerExecutionStatus::Skipped => "skipped",
281 SchedulerExecutionStatus::Failed => "failed",
282 SchedulerExecutionStatus::Blocked => "blocked",
283 }
284}
285
286pub fn artifact_kind_label(kind: GeneratedArtifactKind) -> &'static str {
287 match kind {
288 GeneratedArtifactKind::SystemdService => "systemd-service",
289 GeneratedArtifactKind::SystemdTimer => "systemd-timer",
290 GeneratedArtifactKind::LaunchdPlist => "launchd-plist",
291 GeneratedArtifactKind::TaskSchedulerXml => "task-scheduler-xml",
292 GeneratedArtifactKind::RunnerScript => "runner-script",
293 }
294}