use super::push_failure_notice::fnv_hex;
use super::private_storage::{create_private_directory_all, write_private_file_unsynced};
use super::work_item::WorkItem;
use serde_json::{Map, Value};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
pub const PLAN_VERSION: u64 = 1;
const PLAN_EXTENSION: &str = "plan";
const NAME_SEPARATOR: char = '~';
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StoredPlan {
pub git_dir: PathBuf,
pub item: WorkItem,
pub attempt: u32,
}
fn branch_key(git_dir: &Path, branch_ref: &str) -> String {
return fnv_hex(format!("{}\n{branch_ref}", git_dir.display()).as_str());
}
fn plan_name(plan: &StoredPlan) -> String {
return format!("{}{NAME_SEPARATOR}{}{NAME_SEPARATOR}{}.{PLAN_EXTENSION}", branch_key(plan.git_dir.as_path(), plan.item.branch_ref.as_str()), plan.attempt, fnv_hex(plan.item.oid.as_str()));
}
fn encode(plan: &StoredPlan) -> String {
let mut fields: Map<String, Value> = Map::new();
fields.insert(String::from("v"), Value::from(PLAN_VERSION));
fields.insert(String::from("attempt"), Value::from(plan.attempt));
fields.insert(String::from("gitDir"), Value::from(plan.git_dir.display().to_string()));
fields.insert(String::from("item"), Value::from(plan.item.encode()));
return format!("{}\n", Value::Object(fields));
}
fn decode(text: &str) -> Result<StoredPlan, String> {
let value: Value = serde_json::from_str(text).map_err(|error| { return format!("the plan is not JSON: {error}"); })?;
if value.get("v").and_then(Value::as_u64) != Some(PLAN_VERSION) {
return Err(String::from("the plan has an unsupported version"));
}
let attempt: u32 = value.get("attempt").and_then(Value::as_u64).and_then(|number| { return u32::try_from(number).ok(); }).ok_or_else(|| { return String::from("the plan has no try number"); })?;
let git_dir: &str = value.get("gitDir").and_then(Value::as_str).ok_or_else(|| { return String::from("the plan names no Git directory"); })?;
let item_text: &str = value.get("item").and_then(Value::as_str).ok_or_else(|| { return String::from("the plan holds no push item"); })?;
let item: WorkItem = WorkItem::decode(item_text)?;
return Ok(StoredPlan { git_dir: PathBuf::from(git_dir), item, attempt });
}
pub fn save(directory: &Path, plan: &StoredPlan) -> Result<(), String> {
create_private_directory_all(directory).map_err(|error| { return format!("creating the plan directory failed: {error}"); })?;
let name: String = plan_name(plan);
match write_private_file_unsynced(directory.join(name.as_str()).as_path(), encode(plan).as_bytes()) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(format!("writing the plan failed: {error}")),
}
remove_files_of(directory, branch_key(plan.git_dir.as_path(), plan.item.branch_ref.as_str()).as_str(), Some(name.as_str()));
return Ok(());
}
pub fn remove(directory: &Path, git_dir: &Path, branch_ref: &str) {
remove_files_of(directory, branch_key(git_dir, branch_ref).as_str(), None);
}
fn remove_files_of(directory: &Path, key: &str, keep: Option<&str>) {
let Ok(listing) = std::fs::read_dir(directory) else { return };
let prefix: String = format!("{key}{NAME_SEPARATOR}");
for entry in listing.flatten() {
let name: String = entry.file_name().to_string_lossy().into_owned();
if name.starts_with(prefix.as_str()) && keep != Some(name.as_str()) {
let _removed = std::fs::remove_file(entry.path());
}
}
}
pub fn load(directory: &Path) -> Vec<StoredPlan> {
let Ok(listing) = std::fs::read_dir(directory) else { return Vec::new() };
let mut found: Vec<(SystemTime, StoredPlan)> = Vec::new();
for entry in listing.flatten() {
let path: PathBuf = entry.path();
if path.extension().and_then(|extension| { return extension.to_str(); }) != Some(PLAN_EXTENSION) {
continue;
}
let written: SystemTime = entry.metadata().and_then(|meta| { return meta.modified(); }).unwrap_or(SystemTime::UNIX_EPOCH);
match std::fs::read_to_string(path.as_path()).map_err(|error| { return error.to_string(); }).and_then(|text| { return decode(text.as_str()); }) {
Ok(plan) => found.push((written, plan)),
Err(reason) => {
crate::service_warn!("the retry plan file {} holds no plan and is removed: {reason}", path.display());
let _removed = std::fs::remove_file(path.as_path());
}
}
}
found.sort_by_key(|(written, _)| { return std::cmp::Reverse(*written); });
let mut plans: Vec<StoredPlan> = Vec::new();
for (_, plan) in found {
let same_branch = |kept: &StoredPlan| { return kept.git_dir == plan.git_dir && kept.item.branch_ref == plan.item.branch_ref; };
if !plans.iter().any(same_branch) {
plans.push(plan);
}
}
return plans;
}
#[cfg(test)]
#[path = "push_retry_store_tests.rs"]
mod tests;