Skip to main content

cargo_reclaim/scheduler/
operation_executor.rs

1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use super::model::{
7    GeneratedArtifact, GeneratedArtifactKind, SchedulerCommandOutput, SchedulerExecutionReport,
8    SchedulerExecutionStatus, SchedulerExecutionStep, SchedulerExecutionTotals,
9    SchedulerOperationPlan, SchedulerPlanStep,
10};
11
12pub trait SchedulerOperationBackend {
13    fn ensure_dir(&mut self, path: &Path) -> Result<(), String>;
14    fn write_file(&mut self, path: &Path, contents: &str) -> Result<(), String>;
15    fn set_executable(&mut self, path: &Path) -> Result<(), String>;
16    fn remove_file(&mut self, path: &Path) -> Result<RemoveFileOutcome, String>;
17    fn run_command(&mut self, argv: &[String]) -> Result<SchedulerCommandOutput, String>;
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum RemoveFileOutcome {
22    Removed,
23    NotFound,
24}
25
26#[derive(Debug, Default)]
27pub struct RealSchedulerOperationBackend;
28
29pub fn execute_scheduler_operation(
30    plan: &SchedulerOperationPlan,
31    backend: &mut impl SchedulerOperationBackend,
32) -> SchedulerExecutionReport {
33    let mut steps = Vec::with_capacity(plan.steps.len());
34    let mut totals = SchedulerExecutionTotals::default();
35    let mut blocked = false;
36
37    for step in &plan.steps {
38        let execution_step = if blocked {
39            SchedulerExecutionStep {
40                step: step.clone(),
41                status: SchedulerExecutionStatus::Blocked,
42                message: Some("blocked by an earlier failed scheduler operation step".to_string()),
43                command_output: None,
44            }
45        } else {
46            execute_step(step, &plan.artifacts, backend)
47        };
48
49        if execution_step.status == SchedulerExecutionStatus::Failed {
50            blocked = true;
51        }
52        increment_totals(&mut totals, execution_step.status);
53        steps.push(execution_step);
54    }
55
56    SchedulerExecutionReport {
57        command: plan.command,
58        operation: plan.operation,
59        dry_run: false,
60        platform: plan.platform,
61        artifacts: plan.artifacts.clone(),
62        steps,
63        totals,
64    }
65}
66
67impl SchedulerOperationBackend for RealSchedulerOperationBackend {
68    fn ensure_dir(&mut self, path: &Path) -> Result<(), String> {
69        fs::create_dir_all(path).map_err(error_message)
70    }
71
72    fn write_file(&mut self, path: &Path, contents: &str) -> Result<(), String> {
73        write_file_atomically(path, contents).map_err(error_message)
74    }
75
76    fn set_executable(&mut self, path: &Path) -> Result<(), String> {
77        set_executable(path).map_err(error_message)
78    }
79
80    fn remove_file(&mut self, path: &Path) -> Result<RemoveFileOutcome, String> {
81        match fs::remove_file(path) {
82            Ok(()) => Ok(RemoveFileOutcome::Removed),
83            Err(error) if error.kind() == io::ErrorKind::NotFound => {
84                Ok(RemoveFileOutcome::NotFound)
85            }
86            Err(error) => Err(error_message(error)),
87        }
88    }
89
90    fn run_command(&mut self, argv: &[String]) -> Result<SchedulerCommandOutput, String> {
91        let Some((program, args)) = argv.split_first() else {
92            return Err("command argv must not be empty".to_string());
93        };
94        let output = Command::new(program)
95            .args(args)
96            .output()
97            .map_err(error_message)?;
98        Ok(SchedulerCommandOutput {
99            exit_code: output.status.code(),
100            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
101            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
102        })
103    }
104}
105
106fn execute_step(
107    step: &SchedulerPlanStep,
108    artifacts: &[GeneratedArtifact],
109    backend: &mut impl SchedulerOperationBackend,
110) -> SchedulerExecutionStep {
111    match step {
112        SchedulerPlanStep::EnsureDir { path } => match backend.ensure_dir(path) {
113            Ok(()) => applied(step),
114            Err(message) => failed(step, message, None),
115        },
116        SchedulerPlanStep::WriteFile {
117            path,
118            artifact_kind,
119        } => execute_write_file(step, path, *artifact_kind, artifacts, backend),
120        SchedulerPlanStep::SetExecutable { path } => match backend.set_executable(path) {
121            Ok(()) => applied(step),
122            Err(message) => failed(step, message, None),
123        },
124        SchedulerPlanStep::RemoveFile { path } => match backend.remove_file(path) {
125            Ok(RemoveFileOutcome::Removed) => applied(step),
126            Ok(RemoveFileOutcome::NotFound) => SchedulerExecutionStep {
127                step: step.clone(),
128                status: SchedulerExecutionStatus::Skipped,
129                message: Some("file was not present".to_string()),
130                command_output: None,
131            },
132            Err(message) => failed(step, message, None),
133        },
134        SchedulerPlanStep::RunCommand { argv } => execute_run_command(step, argv, backend),
135    }
136}
137
138fn execute_write_file(
139    step: &SchedulerPlanStep,
140    path: &Path,
141    artifact_kind: GeneratedArtifactKind,
142    artifacts: &[GeneratedArtifact],
143    backend: &mut impl SchedulerOperationBackend,
144) -> SchedulerExecutionStep {
145    let Some(artifact) = artifacts
146        .iter()
147        .find(|artifact| artifact.kind == artifact_kind && artifact.intended_install_path == path)
148    else {
149        return failed(
150            step,
151            format!(
152                "missing generated artifact for {} at {}",
153                super::model::artifact_kind_label(artifact_kind),
154                path.display()
155            ),
156            None,
157        );
158    };
159
160    match backend.write_file(path, &artifact.contents) {
161        Ok(()) => applied(step),
162        Err(message) => failed(step, message, None),
163    }
164}
165
166fn execute_run_command(
167    step: &SchedulerPlanStep,
168    argv: &[String],
169    backend: &mut impl SchedulerOperationBackend,
170) -> SchedulerExecutionStep {
171    match backend.run_command(argv) {
172        Ok(output) if output.exit_code == Some(0) => SchedulerExecutionStep {
173            step: step.clone(),
174            status: SchedulerExecutionStatus::Applied,
175            message: None,
176            command_output: Some(output),
177        },
178        Ok(output) => {
179            let message = match output.exit_code {
180                Some(code) => format!("command exited with status {code}"),
181                None => "command terminated without an exit status".to_string(),
182            };
183            failed(step, message, Some(output))
184        }
185        Err(message) => failed(step, message, None),
186    }
187}
188
189fn applied(step: &SchedulerPlanStep) -> SchedulerExecutionStep {
190    SchedulerExecutionStep {
191        step: step.clone(),
192        status: SchedulerExecutionStatus::Applied,
193        message: None,
194        command_output: None,
195    }
196}
197
198fn failed(
199    step: &SchedulerPlanStep,
200    message: String,
201    command_output: Option<SchedulerCommandOutput>,
202) -> SchedulerExecutionStep {
203    SchedulerExecutionStep {
204        step: step.clone(),
205        status: SchedulerExecutionStatus::Failed,
206        message: Some(message),
207        command_output,
208    }
209}
210
211fn increment_totals(totals: &mut SchedulerExecutionTotals, status: SchedulerExecutionStatus) {
212    match status {
213        SchedulerExecutionStatus::Applied => totals.applied += 1,
214        SchedulerExecutionStatus::Skipped => totals.skipped += 1,
215        SchedulerExecutionStatus::Failed => totals.failed += 1,
216        SchedulerExecutionStatus::Blocked => totals.blocked += 1,
217    }
218}
219
220fn write_file_atomically(path: &Path, contents: &str) -> io::Result<()> {
221    let temp_path = temp_path_for(path)?;
222    fs::write(&temp_path, contents)?;
223    match fs::rename(&temp_path, path) {
224        Ok(()) => Ok(()),
225        Err(rename_error) => replace_file_after_rename_failure(path, &temp_path, rename_error),
226    }
227}
228
229fn replace_file_after_rename_failure(
230    path: &Path,
231    temp_path: &Path,
232    rename_error: io::Error,
233) -> io::Result<()> {
234    match fs::remove_file(path) {
235        Ok(()) => match fs::rename(temp_path, path) {
236            Ok(()) => Ok(()),
237            Err(error) => {
238                let _ = fs::remove_file(temp_path);
239                Err(error)
240            }
241        },
242        Err(error) if error.kind() == io::ErrorKind::NotFound => {
243            let _ = fs::remove_file(temp_path);
244            Err(rename_error)
245        }
246        Err(error) => {
247            let _ = fs::remove_file(temp_path);
248            Err(error)
249        }
250    }
251}
252
253fn temp_path_for(path: &Path) -> io::Result<PathBuf> {
254    let Some(file_name) = path.file_name() else {
255        return Err(io::Error::new(
256            io::ErrorKind::InvalidInput,
257            "write-file path must include a file name",
258        ));
259    };
260    let mut temp_name = file_name.to_os_string();
261    temp_name.push(format!(".tmp.{}", std::process::id()));
262    Ok(path.with_file_name(temp_name))
263}
264
265#[cfg(unix)]
266fn set_executable(path: &Path) -> io::Result<()> {
267    use std::os::unix::fs::PermissionsExt;
268
269    let metadata = fs::metadata(path)?;
270    let mut permissions = metadata.permissions();
271    permissions.set_mode(permissions.mode() | 0o111);
272    fs::set_permissions(path, permissions)
273}
274
275#[cfg(not(unix))]
276fn set_executable(path: &Path) -> io::Result<()> {
277    let _ = fs::metadata(path)?;
278    Ok(())
279}
280
281fn error_message(error: io::Error) -> String {
282    error.to_string()
283}