use super::validate::{save_warning, start_check};
use super::{EditorState, Offer, ask_first, is_checked, level_here};
use crate::app::i18n::fill;
use crate::sim::{Board, LevelKind};
use bevy::prelude::*;
pub fn legacy_save_path() -> std::path::PathBuf {
crate::app::paths::data_dir().join("levels/custom.txt")
}
pub fn custom_dir() -> std::path::PathBuf {
crate::app::paths::data_dir().join("levels/custom")
}
fn is_new_here(path: &std::path::Path) -> bool {
!path.exists()
}
fn filed_name(path: &std::path::Path) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
text.lines()
.find_map(|line| line.strip_prefix("name:"))
.map(|name| name.trim().to_string())
}
fn files_level_named(path: &std::path::Path, name: &str) -> bool {
filed_name(path).is_some_and(|filed| filed == name)
}
pub(super) fn file_name(path: &std::path::Path) -> String {
path.file_name().map_or_else(
|| path.display().to_string(),
|name| name.to_string_lossy().into_owned(),
)
}
fn replaced_level(state: &EditorState, path: &std::path::Path) -> Option<String> {
if is_new_here(path) || state.mine.contains(path) {
return None;
}
Some(filed_name(path).unwrap_or_else(|| file_name(path)))
}
pub(super) fn fresh_name(tr: &crate::app::i18n::Tr) -> String {
let base = tr.ed_default_name;
std::iter::once(base.to_string())
.chain((2..).map(|n| format!("{base} {n}")))
.take(1000)
.find(|name| is_new_here(&save_path(name)))
.unwrap_or_else(|| base.to_string())
}
fn write_level(path: &std::path::Path, text: &str) -> std::io::Result<bool> {
let is_new = is_new_here(path);
crate::app::paths::write_atomic(path, text)?;
Ok(is_new)
}
pub(super) fn ascii_era_path(name: &str) -> std::path::PathBuf {
let mut stem = String::new();
for ch in name.chars().take(40) {
if ch.is_ascii_alphanumeric() {
stem.push(ch.to_ascii_lowercase());
} else if !stem.ends_with('-') {
stem.push('-');
}
}
let stem = match stem.trim_matches('-') {
"" => "level",
trimmed => trimmed,
};
custom_dir().join(format!("{stem}.txt"))
}
pub(super) fn save_path(name: &str) -> std::path::PathBuf {
custom_dir().join(format!(
"{}.txt",
crate::app::paths::safe_stem(name, 40, "level")
))
}
pub(super) fn save_target(state: &EditorState) -> std::path::PathBuf {
match &state.opened {
Some((path, name)) if *name == state.name => path.clone(),
Some(_) | None => save_path(&state.name),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Shelved {
Saved(String),
Taken(String),
}
pub fn shelve(
level: &crate::sim::Level,
replace: bool,
tr: &crate::app::i18n::Tr,
) -> Result<Shelved, String> {
let mut level = level.clone();
level.name = super::naming::tidy_name(&level.name, tr);
let path = save_path(&level.name);
if !replace && !is_new_here(&path) {
return Ok(Shelved::Taken(
filed_name(&path).unwrap_or_else(|| file_name(&path)),
));
}
match crate::app::paths::write_atomic(&path, level.to_text()) {
Ok(()) => Ok(Shelved::Saved(file_name(&path))),
Err(e) => Err(fill(tr.ed_save_failed, &[("e", &e.to_string())])),
}
}
fn check_for_save(state: &mut EditorState, board: &Board, save: (std::path::PathBuf, String)) {
if state.solver.is_none() {
start_check(state, board);
}
if let Some(check) = state.solver.as_mut() {
check.save = Some(save);
}
}
pub(super) fn save_level(
state: &mut EditorState,
board: &Board,
(tr, device): (&crate::app::i18n::Tr, crate::app::gamepad::LastDevice),
saved: &mut MessageWriter<crate::app::LevelSaved>,
) {
let level = level_here(state, board, &state.name);
let path = save_target(state);
let replaced = replaced_level(state, &path);
let prompt = fill(
&Offer::Overwrite.warning(tr, device),
&[("name", replaced.as_deref().unwrap_or_default())],
);
if !ask_first(state, Offer::Overwrite, replaced.is_some(), prompt) {
return;
}
state.feedback = match write_level(&path, &level.to_text()) {
Ok(is_new) => {
let legacy = ascii_era_path(&state.name);
let moved = legacy != path && files_level_named(&legacy, &state.name);
if moved {
let _ = std::fs::remove_file(&legacy);
}
if is_new && !moved {
saved.write(crate::app::LevelSaved);
}
state.mine.insert(path.clone());
state.filed = Some(super::filed_text(state, board));
let filed = fill(tr.ed_saved_to, &[("file", &file_name(&path))]);
match save_warning(state, &level, tr) {
Some(warning) => format!("{warning} - {filed}"),
None if state.kind == LevelKind::Puzzle && !is_checked(state, board) => {
check_for_save(state, board, (path.clone(), state.name.clone()));
format!("{} - {filed}", tr.ed_validating)
}
None => filed,
}
}
Err(e) => fill(tr.ed_save_failed, &[("e", &e.to_string())]),
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn different_names_are_different_files() {
assert_ne!(save_path("First Beach"), save_path("Second Beach"));
assert!(
save_path("Gull Alley").ends_with("gull-alley.txt"),
"{:?}",
save_path("Gull Alley")
);
}
#[test]
fn saving_the_same_level_again_does_not_build_a_second_one() {
let dir = crate::app::paths::test_dir("built");
let path = dir.join("my-beach.txt");
assert!(write_level(&path, "one").expect("wrote"), "a new level");
assert!(
!write_level(&path, "two").expect("wrote"),
"the same level again"
);
assert!(
!write_level(&path, "three").expect("wrote"),
"and again, however long the key is held"
);
assert_eq!(
std::fs::read_to_string(&path).expect("reads"),
"three",
"every save still lands; only the counting stops"
);
std::fs::remove_file(&path).expect("removed");
assert!(write_level(&path, "four").expect("wrote"), "built afresh");
let _ = std::fs::remove_dir_all(&dir);
}
}