rustmotion 0.8.0

A CLI tool that renders motion design videos from JSON scenarios. No browser, no Node.js — just a single Rust binary.
Documentation
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};

use super::Shared;

pub const HISTORY_CAP: usize = 64;

#[derive(Default)]
pub struct History {
    undo: Vec<String>,
    redo: Vec<String>,
}

impl History {
    pub fn record(&mut self, snapshot: String) {
        self.undo.push(snapshot);
        if self.undo.len() > HISTORY_CAP {
            self.undo.remove(0);
        }
        self.redo.clear();
    }

    pub fn undo(&mut self, current: String) -> Option<String> {
        let prev = self.undo.pop()?;
        self.redo.push(current);
        Some(prev)
    }

    pub fn redo(&mut self, current: String) -> Option<String> {
        let next = self.redo.pop()?;
        self.undo.push(current);
        Some(next)
    }

    pub fn can_undo(&self) -> bool {
        !self.undo.is_empty()
    }

    pub fn can_redo(&self) -> bool {
        !self.redo.is_empty()
    }
}

#[derive(Default)]
pub struct HistoryState {
    pub path: Option<PathBuf>,
    pub history: History,
    pub saving: bool,
}

impl HistoryState {
    pub fn ensure_path(&mut self, path: &Path) {
        if self.path.as_deref() != Some(path) {
            self.path = Some(path.to_path_buf());
            self.history = History::default();
        }
    }
}

pub type SharedHistory = Arc<Mutex<HistoryState>>;

pub fn history_slot() -> SharedHistory {
    static SLOT: OnceLock<SharedHistory> = OnceLock::new();
    SLOT.get_or_init(|| Arc::new(Mutex::new(HistoryState::default())))
        .clone()
}

pub fn record_edit(slot: &SharedHistory, path: &Path, snapshot: String) {
    let mut st = slot.lock().unwrap_or_else(|e| e.into_inner());
    st.ensure_path(path);
    st.history.record(snapshot);
}

pub fn set_saving(slot: &SharedHistory, saving: bool) {
    let mut st = slot.lock().unwrap_or_else(|e| e.into_inner());
    st.saving = saving;
}

pub fn undo(shared: &Shared, slot: &SharedHistory) {
    step(shared, slot, true)
}

pub fn redo(shared: &Shared, slot: &SharedHistory) {
    step(shared, slot, false)
}

fn step(shared: &Shared, slot: &SharedHistory, is_undo: bool) {
    let path = {
        let m = shared.lock().unwrap_or_else(|e| e.into_inner());
        m.path.clone()
    };
    let Some(path) = path else {
        return;
    };
    let _ = super::optimistic::take_pending(&super::optimistic::pending_write_slot(), &path);

    let outcome: Result<bool, String> = {
        let mut st = slot.lock().unwrap_or_else(|e| e.into_inner());
        st.ensure_path(&path);
        (|| {
            let current = std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?;
            let target = if is_undo {
                st.history.undo(current)
            } else {
                st.history.redo(current)
            };
            let Some(target) = target else {
                return Ok(false);
            };
            match std::fs::write(&path, &target) {
                Ok(()) => {
                    super::optimistic::note_self_write(
                        &super::optimistic::self_write_slot(),
                        &path,
                        &target,
                    );
                    Ok(true)
                }
                Err(e) => {
                    if is_undo {
                        let _ = st.history.redo(target);
                    } else {
                        let _ = st.history.undo(target);
                    }
                    Err(format!("write: {e}"))
                }
            }
        })()
    };

    match outcome {
        Ok(true) => {
            let disk = std::fs::read_to_string(&path).unwrap_or_default();
            if super::optimistic::adopt_source(shared, &path, &disk).is_err() {
                super::optimistic::clear_self_write(&super::optimistic::self_write_slot(), &path);
            }
            let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
            m.write_error = None;
        }
        Ok(false) => {}
        Err(e) => {
            let mut m = shared.lock().unwrap_or_else(|e2| e2.into_inner());
            m.write_error = Some(e);
            m.generation = m.generation.wrapping_add(1);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::studio::scenario::{empty_scenario, StudioModel};

    #[test]
    fn record_caps_at_64_evicting_oldest() {
        let mut h = History::default();
        for i in 0..(HISTORY_CAP + 5) {
            h.record(format!("s{i}"));
        }
        let mut last = None;
        let mut current = "top".to_string();
        while let Some(s) = h.undo(current.clone()) {
            current = s.clone();
            last = Some(s);
        }
        assert_eq!(last.as_deref(), Some("s5"));
    }

    #[test]
    fn record_clears_redo() {
        let mut h = History::default();
        h.record("s0".into());
        assert!(h.undo("s1".into()).is_some());
        assert!(h.can_redo());
        h.record("s2".into());
        assert!(!h.can_redo(), "a new edit after undo clears redo");
    }

    #[test]
    fn undo_empty_is_noop_and_does_not_touch_redo() {
        let mut h = History::default();
        assert_eq!(h.undo("current".into()), None);
        assert!(!h.can_redo(), "failed undo must not push to redo");
        assert_eq!(h.redo("current".into()), None);
        assert!(!h.can_undo(), "failed redo must not push to undo");
    }

    #[test]
    fn undo_redo_roundtrip_restores() {
        let mut h = History::default();
        h.record("v1".into());
        let prev = h.undo("v2".into()).unwrap();
        assert_eq!(prev, "v1");
        assert!(h.can_redo());
        let next = h.redo(prev).unwrap();
        assert_eq!(next, "v2");
        assert!(h.can_undo());
        assert!(!h.can_redo());
    }

    #[test]
    fn ensure_path_resets_on_file_switch() {
        let mut st = HistoryState::default();
        st.ensure_path(Path::new("/a.json"));
        st.history.record("s".into());
        assert!(st.history.can_undo());
        st.ensure_path(Path::new("/a.json"));
        assert!(st.history.can_undo());
        st.ensure_path(Path::new("/b.json"));
        assert!(!st.history.can_undo());
        assert!(!st.history.can_redo());
    }

    fn temp_file(tag: &str, content: &str) -> PathBuf {
        let p = std::env::temp_dir().join(format!("rm_history_{tag}_{}.json", std::process::id()));
        std::fs::write(&p, content).unwrap();
        p
    }

    fn model_for(path: &Path) -> Shared {
        Arc::new(Mutex::new(StudioModel::new(
            empty_scenario(),
            None,
            Some(path.to_path_buf()),
        )))
    }

    fn local_slot() -> SharedHistory {
        Arc::new(Mutex::new(HistoryState::default()))
    }

    #[test]
    fn undo_rewrites_file_and_redo_restores() {
        let path = temp_file("roundtrip", "STATE_B");
        let shared = model_for(&path);
        let slot = local_slot();

        record_edit(&slot, &path, "STATE_A".into());

        undo(&shared, &slot);
        assert_eq!(std::fs::read_to_string(&path).unwrap(), "STATE_A");
        assert!(slot.lock().unwrap().history.can_redo());

        redo(&shared, &slot);
        assert_eq!(std::fs::read_to_string(&path).unwrap(), "STATE_B");
        assert!(slot.lock().unwrap().history.can_undo());

        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn undo_with_empty_history_is_noop() {
        let path = temp_file("noop", "UNTOUCHED");
        let shared = model_for(&path);
        let slot = local_slot();

        undo(&shared, &slot);
        assert_eq!(std::fs::read_to_string(&path).unwrap(), "UNTOUCHED");
        let m = shared.lock().unwrap();
        assert!(m.write_error.is_none());

        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn record_edit_resets_on_path_change() {
        let a = temp_file("switch_a", "A");
        let b = temp_file("switch_b", "B");
        let slot = local_slot();

        record_edit(&slot, &a, "old_a".into());
        assert!(slot.lock().unwrap().history.can_undo());

        record_edit(&slot, &b, "old_b".into());
        let st = slot.lock().unwrap();
        assert_eq!(st.path.as_deref(), Some(b.as_path()));
        drop(st);
        let shared = model_for(&b);
        undo(&shared, &slot);
        assert_eq!(std::fs::read_to_string(&b).unwrap(), "old_b");
        undo(&shared, &slot);
        assert_eq!(std::fs::read_to_string(&b).unwrap(), "old_b");

        let _ = std::fs::remove_file(&a);
        let _ = std::fs::remove_file(&b);
    }
}