par-term 0.49.0

Cross-platform GPU-accelerated terminal emulator with inline graphics support (Sixel, iTerm2, Kitty)
//! One-time best-effort migration of user data from a legacy config directory
//! into the canonical XDG location (`Config::config_dir()`).
//!
//! Before the config-dir fix, several storage sites resolved their path with
//! `dirs::config_dir().join("par-term")`. On macOS that is
//! `~/Library/Application Support/par-term/`, while the main config and all docs
//! use `Config::config_dir()` = `~/.config/par-term/`. The split could leave
//! profiles, command history, arrangements, session state, the dynamic-profile
//! cache, custom trigger sounds, and discovered ACP agent definitions in a
//! different directory than `config.yaml`.
//!
//! [`migrate_legacy_config_dir`] runs once at startup and moves any such legacy
//! entries into the canonical location. It is a no-op when the two locations
//! already coincide (plain Linux/Windows) and a no-op after the first successful
//! run (the legacy directory is then empty).

use std::path::{Path, PathBuf};

use par_term_config::Config;

/// Best-effort one-time migration of user data into the canonical config dir.
///
/// Computes the legacy location (`dirs::config_dir().join("par-term")`) and, when
/// it differs from `Config::config_dir()`, moves any entries present there but
/// absent in the canonical location. Existing canonical files are never
/// overwritten and failures never abort startup; see `migrate_between`.
///
/// A target that already holds a `config.yaml` is not treated as a migration
/// destination: both sides holding one means nothing moves (an existing
/// install, or a scratch `XDG_CONFIG_HOME` dir with a config of its own).
///
/// `PAR_TERM_NO_MIGRATE` (any non-empty value) skips the migration entirely:
/// the legacy-source design cannot tell a real migration from a run whose
/// `XDG_CONFIG_HOME` merely points elsewhere (ui-test harnesses, wrappers), and
/// such a run would relocate the user's real `~/.config/par-term` into the
/// throwaway dir with no undo.
pub fn migrate_legacy_config_dir() {
    if migration_opted_out(std::env::var_os("PAR_TERM_NO_MIGRATE").as_deref()) {
        log::info!("PAR_TERM_NO_MIGRATE is set — legacy config migration skipped");
        return;
    }
    let canonical = Config::config_dir();
    migrate_from_legacy_roots(legacy_config_dirs(&canonical), &canonical);
}

/// Whether `PAR_TERM_NO_MIGRATE` disables the startup migration. Set to any
/// non-empty value; an empty value reads as unset so `VAR=` cannot brick the
/// migration on hosts where the variable is exported empty.
fn migration_opted_out(value: Option<&std::ffi::OsStr>) -> bool {
    value.is_some_and(|v| !v.is_empty())
}

fn migrate_from_legacy_roots(legacies: Vec<PathBuf>, canonical: &Path) {
    for legacy in legacies {
        let moved = migrate_between(&legacy, canonical);
        if moved > 0 {
            log::info!(
                "Migrated {moved} legacy config file(s)/dir(s) from {legacy:?} to {canonical:?}"
            );
        }
        // Clean up the now-empty legacy dir; silently ignored if entries remain.
        let _ = std::fs::remove_dir(&legacy);
    }
}

/// Locations that may hold config from an earlier par-term, excluding `canonical`.
///
/// Three roots can be stale, and which ones depends on the platform and on whether
/// `XDG_CONFIG_HOME` is set:
///
/// - `dirs::config_dir()/par-term` — on macOS this is
///   `~/Library/Application Support/par-term`, the pre-`adbf39f6` location. On Linux
///   `dirs` already resolves `XDG_CONFIG_HOME`, so it usually equals `canonical`.
/// - `~/.config/par-term` — the canonical location before par-term honoured
///   `XDG_CONFIG_HOME`. Stale only for users who set that variable to something else.
/// - Windows only: `%LOCALAPPDATA%\par-term` — the canonical location for builds on
///   `dirs` ≤ 6. dirs 7 switched `config_dir()` from Local to Roaming AppData, so
///   existing installs must be migrated out of Local or they silently reset.
///
/// Entries equal to `canonical`, and duplicates, are dropped. On a plain Linux
/// or macOS install this returns nothing and the migration is a no-op.
fn legacy_config_dirs(canonical: &Path) -> Vec<PathBuf> {
    let candidates = [
        dirs::config_dir().map(|dir| dir.join("par-term")),
        dirs::home_dir().map(|home| home.join(".config").join("par-term")),
        // dirs 7 moved Windows config_dir() from Local to Roaming AppData; builds
        // on dirs 6 wrote to the Local location, so it joins the legacy list or
        // existing Windows installs silently reset.
        #[cfg(target_os = "windows")]
        std::env::var_os("LOCALAPPDATA").map(|root| PathBuf::from(root).join("par-term")),
    ];
    let mut out: Vec<PathBuf> = Vec::new();
    for candidate in candidates.into_iter().flatten() {
        if candidate != canonical && !out.contains(&candidate) {
            out.push(candidate);
        }
    }
    out
}

/// Move entries from `legacy` into `canonical` that are not already present there.
///
/// Returns the number of entries moved. Best-effort and non-panicking: a missing
/// or unreadable `legacy` returns `0`, existing `canonical` entries are preserved,
/// and per-entry rename failures (e.g. cross-device) are logged and skipped so the
/// source file is never lost. When both directories hold a `config.yaml` nothing
/// moves and `0` is returned.
fn migrate_between(legacy: &Path, canonical: &Path) -> usize {
    // Both sides holding a config.yaml is not a migration: the target has a
    // config of its own (an existing install, or a scratch `XDG_CONFIG_HOME`
    // dir), and sweeping legacy entries into it relocated real user data into
    // a throwaway dir that was later deleted (incident 2026-09-30). Skip
    // loudly; `PAR_TERM_NO_MIGRATE=1` skips this sweep entirely.
    if legacy.join("config.yaml").is_file() && canonical.join("config.yaml").is_file() {
        log::warn!(
            "Config migration skipped: both {legacy:?} and {canonical:?} already contain \
             config.yaml, so {canonical:?} is not an empty migration destination — legacy \
             entries stay in place. Set PAR_TERM_NO_MIGRATE=1 to skip the sweep outright."
        );
        return 0;
    }
    let entries = match std::fs::read_dir(legacy) {
        Ok(e) => e,
        Err(_) => return 0,
    };
    let _ = std::fs::create_dir_all(canonical);
    let mut moved = 0;
    for entry in entries.flatten() {
        let dest = canonical.join(entry.file_name());
        if dest.exists() {
            continue; // never clobber existing canonical files
        }
        match std::fs::rename(entry.path(), &dest) {
            Ok(()) => moved += 1,
            Err(e) => log::warn!(
                "Could not migrate legacy config entry {:?} -> {:?}: {e} (left in place)",
                entry.path(),
                dest,
            ),
        }
    }
    moved
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn canonical_is_never_a_migration_source() {
        let canonical = Config::config_dir();
        let legacy = legacy_config_dirs(&canonical);
        assert!(
            !legacy.contains(&canonical),
            "migrating a directory into itself would be a no-op at best: {legacy:?}"
        );
        let mut unique = legacy.clone();
        unique.sort();
        unique.dedup();
        assert_eq!(
            unique.len(),
            legacy.len(),
            "duplicate legacy roots: {legacy:?}"
        );
    }

    /// The reason `legacy_config_dirs` returns up to three roots rather than one: a user who
    /// sets `XDG_CONFIG_HOME` moves `canonical` away from `~/.config/par-term`, and
    /// their existing config there must still be picked up.
    #[test]
    fn dot_config_is_a_legacy_source_when_canonical_moves_away() {
        let canonical = PathBuf::from("/an/xdg/root/par-term");
        let legacy = legacy_config_dirs(&canonical);
        if let Some(home) = dirs::home_dir() {
            assert!(
                legacy.contains(&home.join(".config").join("par-term")),
                "~/.config/par-term must be migrated when XDG_CONFIG_HOME points elsewhere: {legacy:?}"
            );
        }
    }

    #[test]
    fn moves_all_when_canonical_empty() {
        let legacy = tempdir().unwrap();
        let canonical = tempdir().unwrap();
        fs::write(legacy.path().join("profiles.yaml"), "profiles").unwrap();
        fs::write(legacy.path().join("arrangements.yaml"), "arrangements").unwrap();
        fs::create_dir_all(legacy.path().join("sounds").join("sub")).unwrap();
        fs::write(legacy.path().join("sounds").join("a.wav"), "audio").unwrap();

        let moved = migrate_between(legacy.path(), canonical.path());

        assert_eq!(moved, 3); // profiles.yaml, arrangements.yaml, sounds/
        assert!(canonical.path().join("profiles.yaml").exists());
        assert!(canonical.path().join("arrangements.yaml").exists());
        assert!(canonical.path().join("sounds").join("a.wav").exists());
        assert!(canonical.path().join("sounds").join("sub").exists());
        // legacy is emptied of migrated entries
        assert!(legacy.path().read_dir().unwrap().next().is_none());
    }

    #[test]
    fn does_not_overwrite_existing_canonical() {
        let legacy = tempdir().unwrap();
        let canonical = tempdir().unwrap();
        fs::write(legacy.path().join("profiles.yaml"), "stale-profiles").unwrap();
        fs::create_dir(legacy.path().join("sounds")).unwrap();
        // canonical already has profiles.yaml — must be preserved, not clobbered
        fs::write(canonical.path().join("profiles.yaml"), "current-profiles").unwrap();

        let moved = migrate_between(legacy.path(), canonical.path());

        assert_eq!(moved, 1); // only sounds/ moved; profiles.yaml skipped
        assert_eq!(
            fs::read_to_string(canonical.path().join("profiles.yaml")).unwrap(),
            "current-profiles",
        );
        assert!(canonical.path().join("sounds").exists());
        // the stale profiles.yaml is left in legacy (canonical-wins policy)
        assert!(legacy.path().join("profiles.yaml").exists());
        assert!(!legacy.path().join("sounds").exists());
    }

    /// The 2026-09-30 data-loss incident: a scratch `XDG_CONFIG_HOME` dir that
    /// already had its own config.yaml still received every legacy entry, and
    /// the scratch was later deleted. Both sides holding a config.yaml is not
    /// a migration — nothing may move, and the legacy files must survive.
    #[test]
    fn both_config_yaml_present_moves_nothing() {
        let legacy = tempdir().unwrap();
        let canonical = tempdir().unwrap();
        fs::write(legacy.path().join("config.yaml"), "real user config").unwrap();
        fs::write(legacy.path().join("profiles.yaml"), "real profiles").unwrap();
        fs::write(canonical.path().join("config.yaml"), "scratch config").unwrap();

        let moved = migrate_between(legacy.path(), canonical.path());

        assert_eq!(moved, 0);
        assert!(legacy.path().join("config.yaml").exists());
        assert!(legacy.path().join("profiles.yaml").exists());
        assert!(!canonical.path().join("profiles.yaml").exists());
    }

    #[test]
    fn missing_legacy_is_noop() {
        let legacy = tempdir().unwrap();
        let canonical = tempdir().unwrap();
        let absent = legacy.path().join("does-not-exist");
        assert!(!absent.exists());
        assert_eq!(migrate_between(&absent, canonical.path()), 0);
    }

    /// dirs 7 moved Windows `config_dir()` to Roaming AppData; the pre-dirs-7
    /// Local AppData location must stay a migration source on Windows builds,
    /// or existing installs silently reset.
    #[cfg(target_os = "windows")]
    #[test]
    fn local_appdata_is_a_legacy_source_on_windows() {
        let canonical = Config::config_dir(); // Roaming on dirs 7
        let legacy = legacy_config_dirs(&canonical);
        if let Some(local) = std::env::var_os("LOCALAPPDATA") {
            assert!(
                legacy.contains(&PathBuf::from(local).join("par-term")),
                "%LOCALAPPDATA%\\par-term must be migrated into Roaming on Windows: {legacy:?}"
            );
        }
    }

    #[test]
    fn empty_legacy_moves_nothing() {
        let legacy = tempdir().unwrap();
        let canonical = tempdir().unwrap();
        assert_eq!(migrate_between(legacy.path(), canonical.path()), 0);
        assert!(canonical.path().read_dir().unwrap().next().is_none());
    }

    #[test]
    fn creates_canonical_dir_if_missing() {
        let legacy = tempdir().unwrap();
        fs::write(legacy.path().join("arrangements.yaml"), "x").unwrap();
        let canonical = legacy.path().join("sibling").join("canonical");
        assert!(!canonical.exists());

        let moved = migrate_between(legacy.path(), &canonical);

        assert_eq!(moved, 1);
        assert!(canonical.join("arrangements.yaml").exists());
    }

    /// The incident behind `PAR_TERM_NO_MIGRATE` (observed 2026-09-28): an
    /// isolated-`XDG_CONFIG_HOME` run must be able to opt out, or the user's
    /// real `~/.config/par-term` relocates into the throwaway dir with no
    /// undo. The decision function is tested hermetically — no test here may
    /// `set_var` (the shared-environ hazard documented in
    /// tests/config/config_env_tests.rs).
    #[test]
    fn no_migrate_opt_out_decision() {
        assert!(!migration_opted_out(None));
        assert!(!migration_opted_out(Some(std::ffi::OsStr::new(""))));
        assert!(migration_opted_out(Some(std::ffi::OsStr::new("1"))));
    }

    /// What the gate protects: `migrate_from_legacy_roots` is the operation
    /// that drains a populated legacy root — pinned here so the opt-out has
    /// a measurable stake (this is what a gated-out run must NOT do). The
    /// gate itself is the two-line check in `migrate_legacy_config_dir`;
    /// its end-to-end proof is the sandboxed binary repro on the card.
    #[test]
    fn legacy_roots_migration_drains_a_populated_root() {
        let legacy = tempdir().unwrap();
        let canonical = tempdir().unwrap();
        fs::write(legacy.path().join("config.yaml"), "real user config").unwrap();
        fs::create_dir_all(legacy.path().join("shaders")).unwrap();
        fs::write(legacy.path().join("shaders").join("x.glsl"), "shader").unwrap();

        migrate_from_legacy_roots(vec![legacy.path().to_path_buf()], canonical.path());

        assert!(canonical.path().join("config.yaml").exists());
        assert!(canonical.path().join("shaders").join("x.glsl").exists());
        assert!(!legacy.path().join("config.yaml").exists());
        // The emptied root is cleaned up (the rmtree half of the incident).
        assert!(!legacy.path().exists());
    }
}