bmux_cli 0.0.1-alpha.0

Command-line interface for bmux terminal multiplexer
Documentation
use super::PaneRuntime;
use crate::pane::{LayoutNode, LayoutTree, PaneId, SplitDirection};
use anyhow::{Context, Result};
use bmux_config::ConfigPaths;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};

const RUNTIME_STATE_VERSION: u32 = 1;

#[derive(Debug, Clone)]
pub(super) struct PersistedPaneMeta {
    pub(super) title: String,
    pub(super) shell: String,
}

#[derive(Debug, Clone)]
pub(super) struct PersistedRuntimeState {
    pub(super) layout_tree: LayoutTree,
    pub(super) panes: BTreeMap<PaneId, PersistedPaneMeta>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct RuntimeStateFile {
    version: u32,
    focused_pane: u16,
    layout: PersistedLayoutNode,
    panes: Vec<PersistedPaneEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedPaneEntry {
    id: u16,
    title: String,
    shell: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum PersistedLayoutNode {
    Leaf {
        pane_id: u16,
    },
    Split {
        direction: PersistedSplitDirection,
        ratio: f32,
        first: Box<PersistedLayoutNode>,
        second: Box<PersistedLayoutNode>,
    },
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum PersistedSplitDirection {
    Vertical,
    Horizontal,
}

pub(super) fn load_persisted_runtime_state() -> Result<Option<PersistedRuntimeState>> {
    let path = ConfigPaths::default().runtime_layout_state_file();
    if !path.exists() {
        return Ok(None);
    }

    let bytes = std::fs::read(&path).with_context(|| {
        format!(
            "failed reading persisted runtime state at {}",
            path.display()
        )
    })?;
    let file: RuntimeStateFile = serde_json::from_slice(&bytes).with_context(|| {
        format!(
            "failed parsing persisted runtime state at {}",
            path.display()
        )
    })?;

    if file.version != RUNTIME_STATE_VERSION {
        return Ok(None);
    }

    let layout_root = deserialize_layout_node(&file.layout);
    let pane_order = {
        let mut ids = Vec::new();
        collect_leaf_ids(&layout_root, &mut ids);
        ids
    };
    if pane_order.is_empty() {
        return Ok(None);
    }

    let unique_ids: BTreeSet<PaneId> = pane_order.iter().copied().collect();
    if unique_ids.len() != pane_order.len() {
        return Ok(None);
    }

    let pane_meta_by_id: BTreeMap<PaneId, PersistedPaneMeta> = file
        .panes
        .into_iter()
        .map(|entry| {
            (
                PaneId(entry.id),
                PersistedPaneMeta {
                    title: entry.title,
                    shell: entry.shell,
                },
            )
        })
        .collect();

    if pane_order
        .iter()
        .any(|pane_id| !pane_meta_by_id.contains_key(pane_id))
    {
        return Ok(None);
    }

    let focused = PaneId(file.focused_pane);
    let focused = if unique_ids.contains(&focused) {
        focused
    } else {
        pane_order[0]
    };

    let panes = pane_order
        .iter()
        .filter_map(|pane_id| {
            pane_meta_by_id
                .get(pane_id)
                .cloned()
                .map(|meta| (*pane_id, meta))
        })
        .collect();

    Ok(Some(PersistedRuntimeState {
        layout_tree: LayoutTree {
            root: layout_root,
            focused,
        },
        panes,
    }))
}

pub(super) fn save_persisted_runtime_state(
    layout_tree: &LayoutTree,
    panes: &BTreeMap<PaneId, PaneRuntime>,
    focused_pane: PaneId,
) -> Result<()> {
    let path = ConfigPaths::default().runtime_layout_state_file();
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).with_context(|| {
            format!(
                "failed creating runtime state directory at {}",
                parent.display()
            )
        })?;
    }

    let pane_order = layout_tree.pane_order();
    if pane_order.is_empty() {
        return clear_persisted_runtime_state();
    }

    let state_file = RuntimeStateFile {
        version: RUNTIME_STATE_VERSION,
        focused_pane: focused_pane.0,
        layout: serialize_layout_node(&layout_tree.root),
        panes: pane_order
            .into_iter()
            .filter_map(|pane_id| panes.get(&pane_id).map(|pane| (pane_id, pane)))
            .map(|(pane_id, pane)| PersistedPaneEntry {
                id: pane_id.0,
                title: pane.title.clone(),
                shell: pane.shell.clone(),
            })
            .collect(),
    };

    let payload =
        serde_json::to_vec_pretty(&state_file).context("failed encoding runtime state")?;
    let tmp_path = path.with_extension("json.tmp");
    std::fs::write(&tmp_path, payload).with_context(|| {
        format!(
            "failed writing temporary runtime state file at {}",
            tmp_path.display()
        )
    })?;
    std::fs::rename(&tmp_path, &path)
        .with_context(|| format!("failed replacing runtime state file at {}", path.display()))?;

    Ok(())
}

pub(super) fn clear_persisted_runtime_state() -> Result<()> {
    let path = ConfigPaths::default().runtime_layout_state_file();
    match std::fs::remove_file(&path) {
        Ok(()) => Ok(()),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(error) => Err(error).with_context(|| {
            format!(
                "failed clearing persisted runtime state at {}",
                path.display()
            )
        }),
    }
}

fn serialize_layout_node(node: &LayoutNode) -> PersistedLayoutNode {
    match node {
        LayoutNode::Leaf { pane_id } => PersistedLayoutNode::Leaf { pane_id: pane_id.0 },
        LayoutNode::Split {
            direction,
            ratio,
            first,
            second,
        } => PersistedLayoutNode::Split {
            direction: match direction {
                SplitDirection::Vertical => PersistedSplitDirection::Vertical,
                SplitDirection::Horizontal => PersistedSplitDirection::Horizontal,
            },
            ratio: *ratio,
            first: Box::new(serialize_layout_node(first)),
            second: Box::new(serialize_layout_node(second)),
        },
    }
}

fn deserialize_layout_node(node: &PersistedLayoutNode) -> LayoutNode {
    match node {
        PersistedLayoutNode::Leaf { pane_id } => LayoutNode::Leaf {
            pane_id: PaneId(*pane_id),
        },
        PersistedLayoutNode::Split {
            direction,
            ratio,
            first,
            second,
        } => LayoutNode::Split {
            direction: match direction {
                PersistedSplitDirection::Vertical => SplitDirection::Vertical,
                PersistedSplitDirection::Horizontal => SplitDirection::Horizontal,
            },
            ratio: *ratio,
            first: Box::new(deserialize_layout_node(first)),
            second: Box::new(deserialize_layout_node(second)),
        },
    }
}

fn collect_leaf_ids(node: &LayoutNode, out: &mut Vec<PaneId>) {
    match node {
        LayoutNode::Leaf { pane_id } => out.push(*pane_id),
        LayoutNode::Split { first, second, .. } => {
            collect_leaf_ids(first, out);
            collect_leaf_ids(second, out);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{deserialize_layout_node, serialize_layout_node};
    use crate::pane::{LayoutTree, PaneId, SplitDirection};

    #[test]
    fn layout_node_roundtrips() {
        let mut tree = LayoutTree::two_pane(PaneId(1), PaneId(2), SplitDirection::Vertical, 0.5);
        tree.focused = PaneId(2);
        assert!(tree.split_focused(SplitDirection::Horizontal, PaneId(3), 0.5));

        let serialized = serialize_layout_node(&tree.root);
        let deserialized = deserialize_layout_node(&serialized);

        assert_eq!(tree.root, deserialized);
    }
}