use crate::conflict_sides::SAVED_DIRECTORY;
use crate::diagnostic_log::debug;
use crate::discard_audit::{AUDIT_FILE, append_line, check_appendable};
use crate::discarded_store::{Interrupted, OpenFailure, Store, Tally};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
pub use crate::discarded_prune_audit_line::{Removed, audit_line, prune_line};
pub use crate::discarded_prune_limits::{DEFAULT_MAX_AGE_DAYS, DEFAULT_MAX_BYTES, Limits, parse_size};
pub use crate::discarded_prune_plan::{Reason, plan};
pub use crate::discarded_prune_problems::{Listing, Problem, ProblemKind};
pub use crate::discarded_prune_stamps::{STAMP_RESOLUTION_MILLISECONDS, Stamped, YOUNG_FLOOR_MILLISECONDS, parse_stamp};
pub use crate::discarded_store::Identity;
const TAG: &str = "discardedPrune";
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PruneOutcome {
pub removed: Vec<Removed>,
pub problems: Vec<Problem>,
}
pub fn list_stamped(common_dir: &Path) -> Listing {
return match Store::open(common_dir, SAVED_DIRECTORY) {
Ok(Some(store)) => list_in(&store, common_dir.join(SAVED_DIRECTORY).as_path()),
Ok(None) => Listing::default(),
Err(failure) => Listing { problems: vec![unusable(common_dir.join(SAVED_DIRECTORY).as_path(), &failure)], ..Listing::default() },
};
}
fn unusable(saved_root: &Path, failure: &OpenFailure) -> Problem {
let detail: String = match failure {
OpenFailure::Link => String::from("a symbolic link, or something that is not a directory, stands at it or at its parent `monochromatic-git-policy-cli`, and a removal never follows one"),
OpenFailure::Refused(error) => format!("it cannot be opened: {error}"),
};
return Problem { path: saved_root.to_path_buf(), kind: ProblemKind::StoreUnusable, detail };
}
fn io_text<T>(result: std::io::Result<T>) -> Result<T, String> {
return match result {
Ok(value) => Ok(value),
Err(error) => Err(error.to_string()),
};
}
fn compare_by_time_then_name(left: &Stamped, right: &Stamped) -> std::cmp::Ordering {
return left.millis.cmp(&right.millis).then(left.name.cmp(&right.name));
}
fn problem_at(path: &Path, kind: ProblemKind, detail: String) -> Problem {
return Problem { path: path.to_path_buf(), kind, detail };
}
fn all_were_listed(context: &Removal<'_>, directory_name: &str, now: &[String]) -> bool {
let listed = context.listed_files.get(directory_name);
for file in now {
let was_listed: bool = match listed {
Some(files) => files.contains(file),
None => false,
};
if !was_listed {
return false;
}
}
return true;
}
pub trait BeforeRemoval {
fn before(&mut self, candidate: &Path);
}
impl<Step: FnMut(&Path)> BeforeRemoval for Step {
fn before(&mut self, candidate: &Path) {
self(candidate);
}
}
struct NothingBeforeRemoval;
impl BeforeRemoval for NothingBeforeRemoval {
fn before(&mut self, _candidate: &Path) {}
}
fn list_in(store: &Store, saved_root: &Path) -> Listing {
let mut listing: Listing = Listing::default();
let names = match store.names() {
Ok(found) => found,
Err(error) => {
listing.problems.push(Problem { path: saved_root.to_path_buf(), kind: ProblemKind::StoreUnusable, detail: format!("it cannot be read: {error}") });
return listing;
}
};
for name in names {
let text: String = name.to_string_lossy().into_owned();
let Some(millis) = parse_stamp(text.as_str()) else { continue };
let path: PathBuf = saved_root.join(text.as_str());
match survey_entry(store, name.as_os_str()) {
Ok(None) => {}
Ok(Some((identity, tally))) => {
listing.files += tally.names.len();
listing.entries.push(Stamped { name: text.clone(), millis, bytes: tally.bytes, identity });
listing.listed_files.insert(text, tally.names.into_iter().collect());
}
Err(detail) => listing.problems.push(Problem { path, kind: ProblemKind::EntryUnreadable, detail }),
}
}
listing.entries.sort_by(compare_by_time_then_name);
return listing;
}
fn survey_entry(store: &Store, name: &OsStr) -> Result<Option<(Identity, Tally)>, String> {
let Some(seen) = io_text(store.directory_identity(name))? else {
return Ok(None);
};
let Some(directory) = io_text(store.open_directory(name))? else {
return Ok(None);
};
let opened: Identity = io_text(directory.identity())?;
if opened != seen {
return Err(String::from("it was replaced while it was being read"));
}
return match directory.survey() {
Ok(tally) => Ok(Some((opened, tally))),
Err(Interrupted { error, .. }) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(Interrupted { error, .. }) => Err(error.to_string()),
};
}
pub fn prune(common_dir: &Path, now_millis: i64, limits: Limits, keep: Option<&str>) -> PruneOutcome {
return prune_with(common_dir, now_millis, limits, keep, &mut NothingBeforeRemoval);
}
pub fn prune_with(common_dir: &Path, now_millis: i64, limits: Limits, keep: Option<&str>, before_removal: &mut dyn BeforeRemoval) -> PruneOutcome {
let saved_root: PathBuf = common_dir.join(SAVED_DIRECTORY);
let mut outcome: PruneOutcome = PruneOutcome::default();
let store: Store = match Store::open(common_dir, SAVED_DIRECTORY) {
Ok(Some(opened)) => opened,
Ok(None) => return outcome,
Err(failure) => {
outcome.problems.push(unusable(saved_root.as_path(), &failure));
return outcome;
}
};
let listing: Listing = list_in(&store, saved_root.as_path());
outcome.problems.extend(listing.problems);
let doomed: Vec<(usize, Reason)> = plan(listing.entries.as_slice(), now_millis, limits, keep);
if doomed.is_empty() {
return outcome;
}
let audit_path: PathBuf = common_dir.join(AUDIT_FILE);
if let Err(error) = check_appendable(audit_path.as_path()) {
outcome.problems.push(Problem { path: audit_path, kind: ProblemKind::AuditUnwritable, detail: error.to_string() });
return outcome;
}
let mut folder_bytes: u128 = 0;
for entry in &listing.entries {
folder_bytes += entry.bytes;
}
for (index, reason) in doomed {
let entry: &Stamped = &listing.entries[index];
let path: PathBuf = saved_root.join(entry.name.as_str());
before_removal.before(path.as_path());
let mut context: Removal = Removal { store: &store, audit_path: audit_path.as_path(), limits, folder_bytes, now_millis, listed_files: &listing.listed_files };
remove_entry(&mut context, entry, path, reason, &mut outcome);
}
return outcome;
}
struct Removal<'pass> {
store: &'pass Store,
audit_path: &'pass Path,
limits: Limits,
folder_bytes: u128,
now_millis: i64,
listed_files: &'pass std::collections::HashMap<String, std::collections::HashSet<String>>,
}
fn write_audit(context: &Removal, outcome_word: &str, removed: &Removed, tally: &Tally, outcome: &mut PruneOutcome) {
let line: String = audit_line(outcome_word, removed, tally.names.as_slice(), context.limits, context.folder_bytes, context.now_millis);
if let Err(error) = append_line(context.audit_path, line.as_bytes()) {
debug(TAG, &format!("the audit record of {} could not be written: {error}", removed.path.display()));
outcome.problems.push(Problem { path: removed.path.clone(), kind: ProblemKind::RemovedWithoutAudit, detail: error.to_string() });
}
}
fn remove_entry(context: &mut Removal, entry: &Stamped, path: PathBuf, reason: Reason, outcome: &mut PruneOutcome) {
let name: &OsStr = OsStr::new(entry.name.as_str());
let directory = match context.store.open_directory(name) {
Ok(Some(opened)) => opened,
Ok(None) => return,
Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => {
outcome.problems.push(problem_at(&path, ProblemKind::EntryChanged, String::from("the name is now a link or a file")));
return;
}
Err(error) => {
outcome.problems.push(problem_at(&path, ProblemKind::NotRemoved, error.to_string()));
return;
}
};
match directory.identity() {
Ok(found) if found == entry.identity => {}
Ok(found) if found.device == entry.identity.device && found.inode == entry.identity.inode => match directory.survey() {
Ok(now) if all_were_listed(context, entry.name.as_str(), &now.names) => {}
Ok(_) => {
outcome.problems.push(problem_at(&path, ProblemKind::EntryChanged, String::from("a file was added to it after the pass listed it")));
return;
}
Err(Interrupted { error, .. }) if error.kind() == std::io::ErrorKind::NotFound => return,
Err(Interrupted { error, .. }) => {
outcome.problems.push(problem_at(&path, ProblemKind::NotRemoved, error.to_string()));
return;
}
},
Ok(_) => {
outcome.problems.push(problem_at(&path, ProblemKind::EntryChanged, String::from("the name now stands for another directory")));
return;
}
Err(error) => {
outcome.problems.push(problem_at(&path, ProblemKind::NotRemoved, error.to_string()));
return;
}
}
let mut gone: Removed = Removed { path: path.clone(), bytes: 0, reason };
let tally: Tally = match directory.empty() {
Ok(emptied) => emptied,
Err(Interrupted { tally, error }) => {
if tally.names.is_empty() {
outcome.problems.push(problem_at(&path, ProblemKind::NotRemoved, error.to_string()));
} else {
gone.bytes = tally.bytes;
write_audit(context, "partial", &gone, &tally, outcome);
outcome.problems.push(problem_at(&path, ProblemKind::PartiallyRemoved, error.to_string()));
}
return;
}
};
gone.bytes = tally.bytes;
match context.store.remove_empty_directory(name, entry.identity) {
Ok(true) => {}
Ok(false) => match context.store.directory_identity(name) {
Ok(None) if tally.names.is_empty() => return,
Ok(None) => {}
_ => outcome.problems.push(problem_at(&path, ProblemKind::EntryChanged, String::from("the name now stands for another directory, which was kept; the copies judged are gone"))),
},
Err(error) => {
write_audit(context, "partial", &gone, &tally, outcome);
outcome.problems.push(problem_at(&path, ProblemKind::PartiallyRemoved, format!("its copies are gone but the empty directory could not be removed: {error}")));
return;
}
}
write_audit(context, "pruned", &gone, &tally, outcome);
outcome.removed.push(gone);
}
pub fn directory_name_of(saved_root: &Path, copy: &Path) -> Option<String> {
let first = copy.strip_prefix(saved_root).ok()?.components().next()?;
return Some(first.as_os_str().to_string_lossy().into_owned());
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Report {
pub bytes: u128,
pub copies: usize,
pub saves: usize,
pub oldest: String,
pub problems: Vec<Problem>,
}
pub fn report(common_dir: &Path) -> Option<Report> {
let listing: Listing = list_stamped(common_dir);
if listing.entries.is_empty() && listing.problems.is_empty() {
return None;
}
let mut total_bytes: u128 = 0;
for entry in &listing.entries {
total_bytes += entry.bytes;
}
return Some(Report {
bytes: total_bytes,
copies: listing.files,
saves: listing.entries.len(),
oldest: match listing.entries.first() {
Some(entry) => entry.name.clone(),
None => String::new(),
},
problems: listing.problems,
});
}
pub fn doctor_section(common_dir: &Path, limits: Limits) -> String {
let Some(found) = report(common_dir) else {
return String::new();
};
let mut text: String = String::new();
if found.saves > 0 {
text = format!(
"git monochromatic-git-policy-cli doctor: discarded copies: {} holds {} bytes in {} copies from {} saves; the oldest save is {}. Copies older than {} days \
are removed, and the oldest are removed past {} bytes, never one younger than a day (`discardedCopies.maxAgeDays` and \
`discardedCopies.maxSize` in monochromatic-git-policy-cli.config.jsonc change the limits).\n",
common_dir.join(SAVED_DIRECTORY).display(),
found.bytes,
found.copies,
found.saves,
found.oldest,
limits.max_age_days,
limits.max_bytes,
);
}
for problem in &found.problems {
text.push_str(problem.message().replacen("monochromatic-git-policy-cli: ", "git monochromatic-git-policy-cli doctor: discarded copies: ", 1).as_str());
}
return text;
}
#[cfg(test)]
#[path = "discarded_prune_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "discarded_prune_guard_tests.rs"]
mod guard_tests;