use crate::push_failure_notice::fnv_hex;
use crate::private_storage::{create_private_directory_all, write_private_file_unsynced};
use crate::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 = match serde_json::from_str(text) {
Ok(parsed) => parsed,
Err(error) => return Err(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 Some(number) = value.get("attempt").and_then(Value::as_u64) else {
return Err(String::from("the plan has no try number"));
};
let Ok(attempt) = u32::try_from(number) else {
return Err(String::from("the plan has no try number"));
};
let Some(git_dir) = value.get("gitDir").and_then(Value::as_str) else {
return Err(String::from("the plan names no Git directory"));
};
let Some(item_text) = value.get("item").and_then(Value::as_str) else {
return Err(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> {
if let Err(error) = create_private_directory_all(directory) {
return Err(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());
}
}
}
fn newest_first(left: &(SystemTime, StoredPlan), right: &(SystemTime, StoredPlan)) -> std::cmp::Ordering {
return right.0.cmp(&left.0);
}
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(std::ffi::OsStr::to_str) != Some(PLAN_EXTENSION) {
continue;
}
let written: SystemTime = match entry.metadata() {
Ok(meta) => meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
Err(_) => SystemTime::UNIX_EPOCH,
};
let decoded: Result<StoredPlan, String> = match std::fs::read_to_string(path.as_path()) {
Ok(text) => decode(text.as_str()),
Err(error) => Err(error.to_string()),
};
match decoded {
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(newest_first);
let mut plans: Vec<StoredPlan> = Vec::new();
for (_, plan) in found {
let mut already: bool = false;
for kept in &plans {
if kept.git_dir == plan.git_dir && kept.item.branch_ref == plan.item.branch_ref {
already = true;
break;
}
}
if !already {
plans.push(plan);
}
}
return plans;
}
#[cfg(test)]
#[path = "push_retry_store_tests.rs"]
mod tests;