practicode 0.2.5

Offline-first programming language lessons and coding practice in a Rust terminal UI.
Documentation
use super::*;
use std::{fs::OpenOptions, io::Write};

pub fn load_state(root: &Path, bank: &[Problem]) -> Result<AppState> {
    let path = root.join(STATE_PATH);
    let backup = sibling_path(&path, ".bak");
    if !regular_file_exists(&path)? {
        match fs::symlink_metadata(&backup) {
            Ok(metadata) if metadata.file_type().is_file() => {
                let contents =
                    fs::read(&backup).with_context(|| format!("read {}", backup.display()))?;
                replace_state_file(&path, &contents).with_context(|| {
                    format!("restore {} from {}", path.display(), backup.display())
                })?;
            }
            Ok(_) => {}
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
            Err(error) => {
                return Err(error).with_context(|| format!("inspect backup {}", backup.display()));
            }
        }
    }
    if !regular_file_exists(&path)? {
        return Ok(AppState {
            current_problem: bank[0].id.clone(),
            settings: Settings::default(),
            solved: Vec::new(),
            history: vec![HistoryItem {
                id: bank[0].id.clone(),
                status: "assigned".to_string(),
            }],
            suggested_next_difficulty: "easy".to_string(),
            syntax_progress: HashMap::new(),
            current_syntax_lesson: HashMap::new(),
            syntax_mastery: HashMap::new(),
            completed_syntax_courses: Vec::new(),
        });
    }

    let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
    let mut state: AppState =
        serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
    if !bank
        .iter()
        .any(|problem| problem.id == state.current_problem)
    {
        state.current_problem = bank[0].id.clone();
    }
    normalize_settings(&mut state.settings);
    state.suggested_next_difficulty =
        normalize_suggested_difficulty(&state.suggested_next_difficulty);
    migrate_syntax_mastery(&mut state, super::learning::unix_timestamp_now());
    state.current_syntax_lesson = normalize_current_syntax_lessons(&state.current_syntax_lesson);
    if state.history.is_empty() {
        state.history.push(HistoryItem {
            id: state.current_problem.clone(),
            status: "assigned".to_string(),
        });
    }
    Ok(state)
}

pub fn save_state(root: &Path, state: &AppState) -> Result<()> {
    #[derive(Serialize)]
    struct StateFile<'a> {
        current_problem: &'a str,
        next_number: usize,
        suggested_next_difficulty: &'a str,
        settings: &'a Settings,
        solved: &'a [String],
        history: &'a [HistoryItem],
        #[serde(skip_serializing_if = "HashMap::is_empty")]
        syntax_progress: &'a HashMap<String, Vec<String>>,
        #[serde(skip_serializing_if = "HashMap::is_empty")]
        current_syntax_lesson: &'a HashMap<String, String>,
        #[serde(skip_serializing_if = "HashMap::is_empty")]
        syntax_mastery: &'a HashMap<String, HashMap<String, LessonMastery>>,
        #[serde(skip_serializing_if = "Vec::is_empty")]
        completed_syntax_courses: &'a Vec<String>,
    }

    let path = root.join(STATE_PATH);
    regular_file_exists(&path)?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("create state directory {}", parent.display()))?;
    }
    let file = StateFile {
        current_problem: &state.current_problem,
        next_number: state.history.len() + 1,
        suggested_next_difficulty: &state.suggested_next_difficulty,
        settings: &state.settings,
        solved: &state.solved,
        history: &state.history,
        syntax_progress: &state.syntax_progress,
        current_syntax_lesson: &state.current_syntax_lesson,
        syntax_mastery: &state.syntax_mastery,
        completed_syntax_courses: &state.completed_syntax_courses,
    };
    let text = serde_json::to_string_pretty(&file)
        .with_context(|| format!("serialize {}", path.display()))?
        + "\n";
    replace_state_file(&path, text.as_bytes())
}

fn sibling_path(path: &Path, suffix: &str) -> PathBuf {
    let mut name = path.file_name().unwrap_or_default().to_os_string();
    name.push(suffix);
    path.with_file_name(name)
}

fn replace_state_file(path: &Path, contents: &[u8]) -> Result<()> {
    replace_file(path, contents, true)
}

pub(crate) fn save_user_text(path: &Path, contents: &str) -> Result<()> {
    regular_file_exists(path)?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("create directory {}", parent.display()))?;
    }
    replace_file(path, contents.as_bytes(), false)
}

fn replace_file(path: &Path, contents: &[u8], durable: bool) -> Result<()> {
    let temporary = sibling_path(path, ".tmp");
    let backup = sibling_path(path, ".bak");
    remove_file_if_exists(&temporary)?;

    let result = (|| {
        let mut file = OpenOptions::new()
            .create(true)
            .truncate(true)
            .write(true)
            .open(&temporary)
            .with_context(|| format!("open {}", temporary.display()))?;
        file.write_all(contents)
            .with_context(|| format!("write {}", temporary.display()))?;
        file.flush()
            .with_context(|| format!("flush {}", temporary.display()))?;
        if durable {
            file.sync_all()
                .with_context(|| format!("sync {}", temporary.display()))?;
        }
        drop(file);
        replace_state_file_inner(path, &temporary, &backup)
    })();

    if result.is_err() && temporary.exists() {
        let _ = fs::remove_file(&temporary);
    }
    result
}

fn remove_file_if_exists(path: &Path) -> Result<()> {
    match fs::remove_file(path) {
        Ok(()) => Ok(()),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(error) => Err(error).with_context(|| format!("remove stale {}", path.display())),
    }
}

#[cfg(unix)]
fn replace_state_file_inner(path: &Path, temporary: &Path, backup: &Path) -> Result<()> {
    if path.exists() {
        remove_file_if_exists(backup)?;
        fs::copy(path, backup)
            .with_context(|| format!("preserve {} as {}", path.display(), backup.display()))?;
    }
    fs::rename(temporary, path)
        .with_context(|| format!("replace {} with {}", path.display(), temporary.display()))
}

#[cfg(windows)]
fn replace_state_file_inner(path: &Path, temporary: &Path, backup: &Path) -> Result<()> {
    let had_primary = path.exists();
    replace_state_file_windows_with(
        path,
        temporary,
        backup,
        had_primary,
        remove_file_if_exists,
        |from, to| {
            fs::rename(from, to)?;
            Ok(())
        },
    )
}

#[cfg(any(windows, test))]
fn replace_state_file_windows_with(
    path: &Path,
    temporary: &Path,
    backup: &Path,
    had_primary: bool,
    mut remove_backup: impl FnMut(&Path) -> Result<()>,
    mut rename: impl FnMut(&Path, &Path) -> Result<()>,
) -> Result<()> {
    if had_primary {
        remove_backup(backup)?;
        rename(path, backup)
            .with_context(|| format!("preserve {} as {}", path.display(), backup.display()))?;
    }
    if let Err(replace_error) = rename(temporary, path) {
        if had_primary && let Err(restore_error) = rename(backup, path) {
            bail!(
                "replace {}: {replace_error}; restore {}: {restore_error}",
                path.display(),
                backup.display()
            );
        }
        bail!(
            "replace {} with {}: {replace_error}",
            path.display(),
            temporary.display()
        );
    }
    Ok(())
}

#[cfg(not(any(unix, windows)))]
fn replace_state_file_inner(path: &Path, temporary: &Path, backup: &Path) -> Result<()> {
    if path.exists() {
        remove_file_if_exists(backup)?;
        fs::copy(path, backup)
            .with_context(|| format!("preserve {} as {}", path.display(), backup.display()))?;
    }
    fs::rename(temporary, path)
        .with_context(|| format!("replace {} with {}", path.display(), temporary.display()))
}

pub fn normalize_settings(settings: &mut Settings) {
    settings.language = normalize_language(&settings.language);
    settings.ui_language = normalize_ui_language(&settings.ui_language);
    settings.theme = settings.theme.trim().to_lowercase();
    if !THEMES.contains(&settings.theme.as_str()) {
        settings.theme = "dark".to_string();
    }
    settings.start_mode = normalize_start_mode(&settings.start_mode);
    settings.difficulty = normalize_difficulty(&settings.difficulty);
    settings.topics = normalize_topic_list(&settings.topics);
    settings.avoid_topics = normalize_topic_list(&settings.avoid_topics);
    settings.generate_languages = normalize_language_list(&settings.generate_languages);
    settings.generate_ui_languages = normalize_ui_language_list(&settings.generate_ui_languages);
    settings.next_source = normalize_next_source(&settings.next_source);
    settings.ai_provider = normalize_ai_provider(&settings.ai_provider);
    if settings.ai_model.trim().is_empty() {
        settings.ai_model = default_ai_model();
    }
    settings.ai_effort = normalize_ai_effort(&settings.ai_provider, &settings.ai_effort);
}

pub fn normalize_start_mode(mode: &str) -> String {
    let mode = mode.trim().to_lowercase();
    match mode.as_str() {
        "home" | "learn" | "problems" => mode,
        _ => "home".to_string(),
    }
}

fn normalize_suggested_difficulty(difficulty: &str) -> String {
    match normalize_difficulty(difficulty).as_str() {
        "medium" => "medium".to_string(),
        "hard" => "hard".to_string(),
        _ => "easy".to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    #[test]
    fn windows_recovery_keeps_backup_when_primary_is_absent_and_replace_fails() {
        let operations = RefCell::new(Vec::new());
        let error = replace_state_file_windows_with(
            Path::new("problem-state.json"),
            Path::new("problem-state.json.tmp"),
            Path::new("problem-state.json.bak"),
            false,
            |path| {
                operations
                    .borrow_mut()
                    .push(format!("remove {}", path.display()));
                Ok(())
            },
            |from, to| {
                operations
                    .borrow_mut()
                    .push(format!("rename {} {}", from.display(), to.display()));
                Err(anyhow!("injected replacement failure"))
            },
        )
        .unwrap_err()
        .to_string();

        assert!(error.contains("injected replacement failure"));
        assert_eq!(
            operations.into_inner(),
            ["rename problem-state.json.tmp problem-state.json"]
        );
    }

    #[test]
    fn windows_replacement_restores_primary_after_new_file_rename_fails() {
        let operations = RefCell::new(Vec::new());
        let error = replace_state_file_windows_with(
            Path::new("problem-state.json"),
            Path::new("problem-state.json.tmp"),
            Path::new("problem-state.json.bak"),
            true,
            |path| {
                operations
                    .borrow_mut()
                    .push(format!("remove {}", path.display()));
                Ok(())
            },
            |from, to| {
                operations
                    .borrow_mut()
                    .push(format!("rename {} {}", from.display(), to.display()));
                if from == Path::new("problem-state.json.tmp") {
                    Err(anyhow!("injected replacement failure"))
                } else {
                    Ok(())
                }
            },
        )
        .unwrap_err()
        .to_string();

        assert_eq!(
            operations.into_inner(),
            [
                "remove problem-state.json.bak",
                "rename problem-state.json problem-state.json.bak",
                "rename problem-state.json.tmp problem-state.json",
                "rename problem-state.json.bak problem-state.json",
            ]
        );
        assert_eq!(
            error,
            "replace problem-state.json with problem-state.json.tmp: injected replacement failure"
        );
    }
}