use crate::add_timing::work;
use crate::diagnostic_log::debug;
use crate::package_repair::RepairRequest;
use crate::package_repair_command::{ToolCommand, ToolEnd, ToolRun, find_program, run_tool};
use crate::package_repair_report::{Finding, Outcome, Tool, missing_tool};
use crate::package_repair_tree_clone::clone_tree;
use crate::shell_quote::shell_quote;
use crate::transaction_git::{GitRequest, run_git};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
const TAG: &str = "packageRepairCargo";
const PROGRAM: &str = "cargo";
const MANIFEST: &str = "Cargo.toml";
const LOCK_FILE: &str = "Cargo.lock";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Workspace {
pub root: PathBuf,
pub build_directory: PathBuf,
pub target_directory: PathBuf,
pub members: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum BuildStatePlan {
Nothing(String),
CleanOnly,
CloneThenClean {
from: PathBuf,
to: PathBuf,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StateFacts {
pub source_build: Option<PathBuf>,
pub source_build_exists: bool,
pub destination_build_exists: bool,
pub destination_target_exists: bool,
}
pub fn parse_metadata(text: &str) -> Result<Workspace, String> {
let metadata: serde_json::Value = serde_json::from_str(text).map_err(|error| { return format!("the output of cargo metadata is not JSON ({error})"); })?;
let text_field = |key: &str| -> Option<PathBuf> { return metadata.get(key).and_then(serde_json::Value::as_str).map(PathBuf::from); };
let root: PathBuf = text_field("workspace_root").ok_or_else(|| { return String::from("cargo metadata names no workspace_root"); })?;
let target_directory: PathBuf = text_field("target_directory").ok_or_else(|| { return String::from("cargo metadata names no target_directory"); })?;
let build_directory: PathBuf = text_field("build_directory").unwrap_or_else(|| { return target_directory.clone(); });
let members: Vec<String> = metadata
.get("packages")
.and_then(serde_json::Value::as_array)
.map(|packages: &Vec<serde_json::Value>| { return packages.iter().filter_map(|package: &serde_json::Value| { return package.get("name").and_then(serde_json::Value::as_str).map(str::to_owned); }).collect(); })
.ok_or_else(|| { return String::from("cargo metadata names no packages"); })?;
return Ok(Workspace { root, build_directory, target_directory, members });
}
pub fn distinct_workspaces(mut found: Vec<Workspace>) -> Vec<Workspace> {
found.sort_by(|left: &Workspace, right: &Workspace| { return left.root.cmp(&right.root); });
found.dedup_by(|later: &mut Workspace, earlier: &mut Workspace| { return later.root == earlier.root; });
return found;
}
pub fn plan_build_state(destination: &Workspace, destination_root: &Path, facts: &StateFacts) -> BuildStatePlan {
let Some(source_build) = facts.source_build.as_ref() else {
return BuildStatePlan::Nothing(String::from("the source has no such workspace, so no state was carried"));
};
if *source_build == destination.build_directory {
return BuildStatePlan::Nothing(String::from("the source and the worktree share one build directory, which a clean would damage"));
}
if destination.build_directory.starts_with(destination_root) {
return if facts.destination_build_exists || facts.destination_target_exists {
BuildStatePlan::CleanOnly
} else {
BuildStatePlan::Nothing(String::from("the build directory is inside the worktree and was not carried"))
};
}
if facts.destination_build_exists {
return BuildStatePlan::Nothing(String::from("the worktree's build directory already holds state of its own"));
}
if facts.source_build_exists {
return BuildStatePlan::CloneThenClean { from: source_build.clone(), to: destination.build_directory.clone() };
}
return if facts.destination_target_exists { BuildStatePlan::CleanOnly } else { BuildStatePlan::Nothing(String::from("neither side holds build state")) };
}
pub fn clean_arguments(members: &[String]) -> Vec<OsString> {
let mut arguments: Vec<OsString> = ["clean", "--offline"].iter().map(OsString::from).collect();
for member in members {
arguments.push(OsString::from("--package"));
arguments.push(OsString::from(member));
}
return arguments;
}
fn manual_command(root: &Path, members: &[String]) -> String {
let words: Vec<String> = clean_arguments(members).iter().map(|word: &OsString| { return shell_quote(word.as_encoded_bytes()); }).collect();
return format!("cd {} && {PROGRAM} {}", shell_quote(root.as_os_str().as_encoded_bytes()), words.join(" "));
}
fn manifest_directories(request: &RepairRequest<'_>) -> Result<Vec<PathBuf>, String> {
use std::os::unix::ffi::OsStrExt;
let listing = GitRequest::new(request.destination, &["ls-files", "-z", "--", "*Cargo.toml"]);
let output = run_git(request.git, &listing).map_err(|error| { return format!("git could not list the tracked manifests ({error})"); })?;
if !output.succeeded() {
return Err(format!("git could not list the tracked manifests: {}", output.error_text()));
}
let mut directories: Vec<PathBuf> = output
.stdout
.split(|byte: &u8| { return *byte == 0; })
.filter(|entry: &&[u8]| { return !entry.is_empty(); })
.map(|entry: &[u8]| { return request.destination.join(std::ffi::OsStr::from_bytes(entry)); })
.filter(|path: &PathBuf| { return path.file_name().is_some_and(|name| { return name == MANIFEST; }) && path.is_file(); })
.filter_map(|path: PathBuf| { return path.parent().map(Path::to_path_buf); })
.collect();
directories.sort();
directories.dedup();
return Ok(directories);
}
fn read_workspace(cargo: &Path, directory: &Path, limit: std::time::Duration) -> Result<Workspace, String> {
let arguments: Vec<OsString> = ["metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"].iter().map(OsString::from).collect();
let command: ToolCommand = ToolCommand { arguments, directory: PathBuf::from(directory), set: Vec::new() };
let run: ToolRun = run_tool(cargo, &command, limit);
return match run.end {
ToolEnd::Succeeded => parse_metadata(String::from_utf8_lossy(run.stdout.as_slice()).as_ref()),
ToolEnd::Failed(reason) => Err(format!("cargo metadata in {} failed: {reason}", directory.display())),
ToolEnd::TimedOut => Err(format!("cargo metadata in {} showed no sign of work for the quiet window and was stopped", directory.display())),
};
}
fn read_lock(root: &Path) -> Option<Vec<u8>> {
return std::fs::read(root.join(LOCK_FILE)).ok();
}
fn restore_lock(root: &Path, before: Option<&[u8]>) {
let path: PathBuf = root.join(LOCK_FILE);
let after: Option<Vec<u8>> = std::fs::read(&path).ok();
if after.as_deref() == before {
return;
}
let outcome: std::io::Result<()> = match before {
Some(bytes) => std::fs::write(&path, bytes),
None => std::fs::remove_file(&path),
};
match outcome {
Ok(()) => debug(TAG, format!("{} was put back as checked out after the clean", path.display()).as_str()),
Err(error) => debug(TAG, format!("{} changed during the clean and could not be put back ({error})", path.display()).as_str()),
}
}
enum Done {
Skipped,
Cleaned {
cloned: bool,
left_out: usize,
},
Failed(Finding),
}
fn repair_workspace(cargo: &Path, workspace: &Workspace, destination_root: &Path, request: &RepairRequest<'_>, clone_workers: usize) -> Done {
let manual: String = manual_command(workspace.root.as_path(), workspace.members.as_slice());
let failed = |reason: String| -> Done { return Done::Failed(Finding { tool: Tool::Cargo, outcome: Outcome::Failed { reason, run_instead: manual.clone() } }); };
let Ok(relative) = workspace.root.strip_prefix(destination_root) else {
debug(TAG, format!("{} lies outside {} and is left alone", workspace.root.display(), destination_root.display()).as_str());
return Done::Skipped;
};
let source_workspace: PathBuf = request.source.join(relative);
let source_build: Option<PathBuf> = if source_workspace.join(MANIFEST).is_file() {
match work("cargoMetadataSource", || { return read_workspace(cargo, source_workspace.as_path(), request.environment.quiet_window); }) {
Ok(found) => Some(found.build_directory),
Err(reason) => {
debug(TAG, format!("the source workspace {} cannot be read: {reason}", source_workspace.display()).as_str());
None
}
}
} else {
None
};
let facts: StateFacts = StateFacts {
source_build_exists: source_build.as_ref().is_some_and(|path: &PathBuf| { return path.exists(); }),
source_build,
destination_build_exists: workspace.build_directory.exists(),
destination_target_exists: workspace.target_directory.exists(),
};
let plan: BuildStatePlan = plan_build_state(workspace, destination_root, &facts);
debug(TAG, format!("{}: {plan:?}", workspace.root.display()).as_str());
let (cloned, left_out): (bool, usize) = match &plan {
BuildStatePlan::Nothing(_) => return Done::Skipped,
BuildStatePlan::CleanOnly => (false, 0),
BuildStatePlan::CloneThenClean { from, to } => {
match work("cargoCloneTree", || { return clone_tree(from.as_path(), to.as_path(), clone_workers); }) {
Ok(made) => {
debug(TAG, format!("cloned {} entries from {} to {}", made.made, from.display(), to.display()).as_str());
for path in &made.left_out {
debug(TAG, format!("{} could not be read and is not in the clone", from.join(path).display()).as_str());
}
(true, made.left_out.len())
}
Err(reason) => return failed(format!("the build state could not be cloned: {reason}")),
}
}
};
let command: ToolCommand = ToolCommand { arguments: clean_arguments(workspace.members.as_slice()), directory: workspace.root.clone(), set: Vec::new() };
let lock_before: Option<Vec<u8>> = read_lock(workspace.root.as_path());
let run: ToolRun = work("cargoClean", || { return run_tool(cargo, &command, request.environment.quiet_window); });
restore_lock(workspace.root.as_path(), lock_before.as_deref());
return match run.end {
ToolEnd::Succeeded => Done::Cleaned { cloned, left_out },
ToolEnd::Failed(reason) => failed(reason),
ToolEnd::TimedOut => failed(String::from("it showed no sign of work for the quiet window and was stopped")),
};
}
fn map_concurrent<T: Sync, R: Send>(items: &[T], workers: usize, label: &(dyn Fn(&T) -> String + Sync), work: &(dyn Fn(&T) -> R + Sync)) -> Vec<Result<R, String>> {
let next: AtomicUsize = AtomicUsize::new(0);
let mut results: Vec<(usize, Result<R, String>)> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..workers.max(1).min(items.len().max(1)))
.map(|_worker: usize| {
return scope.spawn(|| {
let mut done: Vec<(usize, Result<R, String>)> = Vec::new();
loop {
let index: usize = next.fetch_add(1, Ordering::Relaxed);
let Some(item) = items.get(index) else { break };
let outcome: Result<R, String> = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { return work(item); }))
.map_err(|payload: Box<dyn std::any::Any + Send>| { return format!("{} stopped because its worker panicked: {}", label(item), panic_words(payload.as_ref())); });
done.push((index, outcome));
}
return done;
});
})
.collect();
return handles.into_iter().flat_map(|handle| { return handle.join().unwrap_or_else(|payload| { std::panic::resume_unwind(payload) }); }).collect();
});
results.sort_by_key(|(index, _result): &(usize, Result<R, String>)| { return *index; });
return results.into_iter().map(|(_index, result): (usize, Result<R, String>)| { return result; }).collect();
}
fn panic_words(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(text) = payload.downcast_ref::<&str>() {
return (*text).to_string();
}
if let Some(text) = payload.downcast_ref::<String>() {
return text.clone();
}
return String::from("no message");
}
fn left_out_words(left_out: usize) -> String {
return match left_out {
0 => String::new(),
1 => String::from("; 1 unreadable entry was left out"),
many => format!("; {many} unreadable entries were left out"),
};
}
pub fn repair_cargo(request: &RepairRequest<'_>) -> Vec<Finding> {
let started: Instant = Instant::now();
let directories: Vec<PathBuf> = match manifest_directories(request) {
Ok(found) => found,
Err(reason) => {
debug(TAG, format!("{}: {reason}", request.destination.display()).as_str());
return Vec::new();
}
};
if directories.is_empty() {
return Vec::new();
}
let Some(cargo) = find_program(PROGRAM, request.environment.path()) else {
return vec![missing_tool(Tool::Cargo, PROGRAM, "cargo clean --offline --package <member> in each Cargo workspace of the worktree")];
};
let workers: usize = request.environment.workers;
let read: Vec<Result<Workspace, String>> = map_concurrent(directories.as_slice(), workers, &|directory: &PathBuf| { return format!("reading the Cargo workspace at {}", directory.display()); }, &|directory: &PathBuf| { return work("cargoMetadataWorktree", || { return read_workspace(cargo.as_path(), directory.as_path(), request.environment.quiet_window); }); })
.into_iter()
.map(|outcome: Result<Result<Workspace, String>, String>| { return outcome.and_then(|inner: Result<Workspace, String>| { return inner; }); })
.collect();
let mut findings: Vec<Finding> = Vec::new();
let mut found: Vec<Workspace> = Vec::new();
for (directory, outcome) in directories.iter().zip(read) {
match outcome {
Ok(workspace) => found.push(workspace),
Err(reason) => findings.push(Finding { tool: Tool::Cargo, outcome: Outcome::Failed { reason, run_instead: manual_command(directory.as_path(), &[String::from("<member>")]) } }),
}
}
let workspaces: Vec<Workspace> = distinct_workspaces(found);
let destination_root: PathBuf = std::fs::canonicalize(request.destination).unwrap_or_else(|_error| { return request.destination.to_path_buf(); });
let clone_workers: usize = if workspaces.len() > 1 { 1 } else { workers };
let done: Vec<Done> = map_concurrent(workspaces.as_slice(), workers, &|workspace: &Workspace| { return format!("the repair of the Cargo workspace at {}", workspace.root.display()); }, &|workspace: &Workspace| { return work("cargoRepairWorkspace", || { return repair_workspace(cargo.as_path(), workspace, destination_root.as_path(), request, clone_workers); }); })
.into_iter()
.zip(workspaces.iter())
.map(|(outcome, workspace): (Result<Done, String>, &Workspace)| {
return outcome.unwrap_or_else(|reason: String| { return Done::Failed(Finding { tool: Tool::Cargo, outcome: Outcome::Failed { reason, run_instead: manual_command(workspace.root.as_path(), &[String::from("<member>")]) } }); });
})
.collect();
let mut cleaned: usize = 0;
let mut cloned: usize = 0;
let mut left_out: usize = 0;
for outcome in done {
match outcome {
Done::Skipped => {}
Done::Cleaned { cloned: was_cloned, left_out: unreadable } => {
cleaned += 1;
cloned += usize::from(was_cloned);
left_out += unreadable;
}
Done::Failed(finding) => findings.push(finding),
}
}
if cleaned > 0 {
let workspace_noun: &str = if cleaned == 1 { "workspace" } else { "workspaces" };
let directory_noun: &str = if cloned == 1 { "build directory" } else { "build directories" };
findings.push(Finding { tool: Tool::Cargo, outcome: Outcome::Repaired { detail: format!("Cargo kept dependency builds and cleaned the worktree's own crates in {cleaned} {workspace_noun} ({cloned} {directory_noun} cloned{})", left_out_words(left_out)), millis: started.elapsed().as_millis() } });
}
return findings;
}
#[cfg(test)]
#[path = "package_repair_cargo_tests.rs"]
mod tests;