par-term 0.39.0

Cross-platform GPU-accelerated terminal emulator with inline graphics support (Sixel, iTerm2, Kitty)
Documentation
//! Storage utilities for arrangement persistence
//!
//! Arrangements are stored in `~/.config/par-term/arrangements.yaml`

use super::{ArrangementManager, WindowArrangement};
use anyhow::{Context, Result};
use par_term_config::Config;
use std::path::PathBuf;

/// Get the default arrangements file path
pub fn arrangements_path() -> PathBuf {
    Config::config_dir().join("arrangements.yaml")
}

/// Load arrangements from the default location
pub fn load_arrangements() -> Result<ArrangementManager> {
    load_arrangements_from(arrangements_path())
}

/// Load arrangements from a specific file
pub fn load_arrangements_from(path: PathBuf) -> Result<ArrangementManager> {
    crate::debug_info!("ARRANGEMENT", "Loading arrangements from {:?}", path);
    if !path.exists() {
        crate::debug_info!(
            "ARRANGEMENT",
            "No arrangements file found at {:?}, starting with empty arrangements",
            path
        );
        return Ok(ArrangementManager::new());
    }

    let contents = std::fs::read_to_string(&path)
        .with_context(|| format!("Failed to read arrangements from {:?}", path))?;

    crate::debug_info!(
        "ARRANGEMENT",
        "Read {} bytes from arrangements file",
        contents.len()
    );

    if contents.trim().is_empty() {
        crate::debug_info!(
            "ARRANGEMENT",
            "Arrangements file is empty, starting with empty arrangements"
        );
        return Ok(ArrangementManager::new());
    }

    let arrangements: Vec<WindowArrangement> = serde_yaml_ng::from_str(&contents)
        .with_context(|| format!("Failed to parse arrangements from {:?}", path))?;

    crate::debug_info!(
        "ARRANGEMENT",
        "Parsed {} arrangements from {:?}",
        arrangements.len(),
        path
    );
    for a in &arrangements {
        crate::debug_info!(
            "ARRANGEMENT",
            "  - {}: {} ({} windows)",
            a.id,
            a.name,
            a.windows.len()
        );
    }
    Ok(ArrangementManager::from_arrangements(arrangements))
}

/// Save arrangements to the default location
pub fn save_arrangements(manager: &ArrangementManager) -> Result<()> {
    save_arrangements_to(manager, arrangements_path())
}

/// Save arrangements to a specific file
///
/// SEC-021: written through [`crate::atomic_save`], which creates the parent
/// directory, stages the write in a sibling temp file at mode `0o600`, fsyncs
/// it and renames it into place. Arrangements are par-term's own state and
/// record the user's working directories and tmux session names, so `0o600` is
/// the right mode; and a truncated file is read back as an empty list, which
/// silently loses every saved arrangement.
pub fn save_arrangements_to(manager: &ArrangementManager, path: PathBuf) -> Result<()> {
    let arrangements = manager.to_vec();

    crate::atomic_save::save_yaml_atomic(&path, &arrangements)
        .with_context(|| format!("Failed to write arrangements to {:?}", path))?;

    log::info!("Saved {} arrangements to {:?}", arrangements.len(), path);
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::arrangements::{MonitorInfo, TabSnapshot, WindowArrangement, WindowSnapshot};
    use tempfile::tempdir;
    use uuid::Uuid;

    #[test]
    fn test_load_nonexistent_file() {
        let temp = tempdir().expect("failed to create temp dir");
        let path = temp.path().join("nonexistent.yaml");

        let manager = load_arrangements_from(path)
            .expect("loading from nonexistent path should return empty manager");
        assert!(manager.is_empty());
    }

    #[test]
    fn test_load_empty_file() {
        let temp = tempdir().expect("failed to create temp dir");
        let path = temp.path().join("empty.yaml");
        std::fs::write(&path, "").expect("failed to write empty file");

        let manager =
            load_arrangements_from(path).expect("loading empty file should return empty manager");
        assert!(manager.is_empty());
    }

    #[test]
    fn test_save_and_load_roundtrip() {
        let temp = tempdir().expect("failed to create temp dir");
        let path = temp.path().join("arrangements.yaml");

        let mut manager = ArrangementManager::new();
        manager.add(WindowArrangement {
            id: Uuid::new_v4(),
            name: "Work Setup".to_string(),
            monitor_layout: vec![MonitorInfo {
                name: Some("Main".to_string()),
                index: 0,
                position: (0, 0),
                size: (1920, 1080),
                scale_factor: 1.0,
            }],
            windows: vec![WindowSnapshot {
                monitor: MonitorInfo {
                    name: Some("Main".to_string()),
                    index: 0,
                    position: (0, 0),
                    size: (1920, 1080),
                    scale_factor: 1.0,
                },
                position_relative: (100, 100),
                size: (800, 600),
                tabs: vec![TabSnapshot {
                    cwd: Some("/home/user/work".to_string()),
                    title: "work".to_string(),
                    custom_color: None,
                    user_title: None,
                    custom_icon: None,
                }],
                active_tab_index: 0,
                tmux_session_name: None,
            }],
            created_at: "2024-01-01T00:00:00Z".to_string(),
            order: 0,
        });

        save_arrangements_to(&manager, path.clone()).expect("failed to save arrangements");

        let loaded = load_arrangements_from(path).expect("failed to load saved arrangements");
        assert_eq!(loaded.len(), 1);

        let arrangements = loaded.arrangements_ordered();
        assert_eq!(arrangements[0].name, "Work Setup");
        assert_eq!(arrangements[0].windows.len(), 1);
        assert_eq!(arrangements[0].windows[0].tabs.len(), 1);
        assert_eq!(
            arrangements[0].windows[0].tabs[0].cwd,
            Some("/home/user/work".to_string())
        );
    }

    #[test]
    fn test_roundtrip_preserves_custom_tab_properties() {
        let temp = tempdir().expect("failed to create temp dir");
        let path = temp.path().join("arrangements.yaml");

        let mut manager = ArrangementManager::new();
        manager.add(WindowArrangement {
            id: Uuid::new_v4(),
            name: "Custom Props".to_string(),
            monitor_layout: vec![MonitorInfo {
                name: Some("Main".to_string()),
                index: 0,
                position: (0, 0),
                size: (1920, 1080),
                scale_factor: 1.0,
            }],
            windows: vec![WindowSnapshot {
                monitor: MonitorInfo {
                    name: Some("Main".to_string()),
                    index: 0,
                    position: (0, 0),
                    size: (1920, 1080),
                    scale_factor: 1.0,
                },
                position_relative: (0, 0),
                size: (800, 600),
                tabs: vec![
                    TabSnapshot {
                        cwd: Some("/home/user".to_string()),
                        title: "My Custom Tab".to_string(),
                        custom_color: Some([255, 128, 0]),
                        user_title: Some("My Custom Tab".to_string()),
                        custom_icon: Some("🔥".to_string()),
                    },
                    TabSnapshot {
                        cwd: Some("/tmp".to_string()),
                        title: "Tab 2".to_string(),
                        custom_color: None,
                        user_title: None,
                        custom_icon: Some("📁".to_string()),
                    },
                    TabSnapshot {
                        cwd: None,
                        title: "Colored Only".to_string(),
                        custom_color: Some([0, 200, 100]),
                        user_title: None,
                        custom_icon: None,
                    },
                ],
                active_tab_index: 1,
                tmux_session_name: None,
            }],
            created_at: "2024-01-01T00:00:00Z".to_string(),
            order: 0,
        });

        save_arrangements_to(&manager, path.clone())
            .expect("failed to save custom-props arrangements");

        let loaded =
            load_arrangements_from(path).expect("failed to load custom-props arrangements");
        let arrangements = loaded.arrangements_ordered();
        let tabs = &arrangements[0].windows[0].tabs;

        // Tab 0: all custom properties set
        assert_eq!(tabs[0].custom_color, Some([255, 128, 0]));
        assert_eq!(tabs[0].user_title, Some("My Custom Tab".to_string()));
        assert_eq!(tabs[0].custom_icon, Some("🔥".to_string()));

        // Tab 1: only custom icon
        assert_eq!(tabs[1].custom_color, None);
        assert_eq!(tabs[1].user_title, None);
        assert_eq!(tabs[1].custom_icon, Some("📁".to_string()));

        // Tab 2: only custom color
        assert_eq!(tabs[2].custom_color, Some([0, 200, 100]));
        assert_eq!(tabs[2].user_title, None);
        assert_eq!(tabs[2].custom_icon, None);
    }

    #[test]
    fn test_save_creates_parent_directory() {
        let temp = tempdir().expect("failed to create temp dir");
        let path = temp
            .path()
            .join("nested")
            .join("dir")
            .join("arrangements.yaml");

        let manager = ArrangementManager::new();
        save_arrangements_to(&manager, path.clone())
            .expect("failed to save arrangements to nested dir");

        assert!(path.exists());
    }

    /// SEC-021: arrangements record working directories and tmux session names,
    /// so the saved file must not be readable by other users on the machine.
    #[cfg(unix)]
    #[test]
    fn test_saved_arrangements_are_owner_only() {
        use std::os::unix::fs::PermissionsExt;

        let temp = tempdir().expect("failed to create temp dir");
        let path = temp.path().join("arrangements.yaml");

        // Seed a world-readable file so the save has something to tighten.
        std::fs::write(&path, "[]").expect("failed to seed arrangements file");
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
            .expect("failed to chmod");

        save_arrangements_to(&ArrangementManager::new(), path.clone())
            .expect("failed to save arrangements");

        let mode = std::fs::metadata(&path)
            .expect("failed to stat arrangements")
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
    }

    #[test]
    fn test_load_corrupt_file_returns_error() {
        let temp = tempdir().expect("failed to create temp dir");
        let path = temp.path().join("corrupt.yaml");
        std::fs::write(&path, "not: valid: yaml: [[[").expect("failed to write corrupt file");

        let result = load_arrangements_from(path);
        assert!(result.is_err());
    }
}