harn-vm 0.7.20

Async bytecode virtual machine for the Harn programming language
Documentation
use super::*;
use crate::value::VmValue;
use std::collections::BTreeMap;
use std::rc::Rc;

#[test]
fn daemon_snapshot_roundtrip_preserves_state() {
    let dir = std::env::temp_dir().join(format!("harn-daemon-{}", uuid::Uuid::now_v7()));
    let path = dir.join("daemon.json");
    let snapshot = DaemonSnapshot {
        daemon_state: "idle".to_string(),
        visible_messages: vec![serde_json::json!({"role": "user", "content": "hi"})],
        total_iterations: 2,
        idle_backoff_ms: 500,
        ..Default::default()
    };
    persist_snapshot(path.to_str().unwrap(), &snapshot).unwrap();
    let loaded = load_snapshot(path.to_str().unwrap()).unwrap();
    assert_eq!(loaded.daemon_state, "idle");
    assert_eq!(loaded.visible_messages.len(), 1);
    assert_eq!(loaded.total_iterations, 2);
    let _ = std::fs::remove_dir_all(dir);
}

#[test]
fn detect_watch_changes_reports_modified_files() {
    let dir = std::env::temp_dir().join(format!("harn-watch-{}", uuid::Uuid::now_v7()));
    std::fs::create_dir_all(&dir).unwrap();
    let path = dir.join("watched.txt");
    std::fs::write(&path, "one").unwrap();
    let paths = vec![path.to_string_lossy().into_owned()];
    let mut state = watch_state(&paths);
    // `file_stamp` snaps mtime to nanoseconds, so any non-zero sleep
    // crosses the resolution of every filesystem Harn targets. Keep a
    // modest 50ms pause so the OS has time to flush metadata on slow
    // CI hosts without inflating test runtime. Loop the "modify +
    // detect" cycle up to 20 times (~1s cap) so a quantized mtime on
    // WSL/network filesystems gets a second chance before failing.
    let mut changed = Vec::new();
    for attempt in 0..20 {
        std::thread::sleep(std::time::Duration::from_millis(50));
        std::fs::write(&path, format!("two-{attempt}")).unwrap();
        changed = detect_watch_changes(&paths, &mut state);
        if !changed.is_empty() {
            break;
        }
    }
    assert_eq!(changed, paths, "watched file mtime never advanced");
    let _ = std::fs::remove_dir_all(dir);
}

#[test]
fn parse_daemon_config_reads_top_level_options() {
    let mut options = BTreeMap::new();
    options.insert(
        "persist_path".to_string(),
        VmValue::String(Rc::from("/tmp/daemon.json")),
    );
    options.insert(
        "resume_path".to_string(),
        VmValue::String(Rc::from("/tmp/daemon-resume.json")),
    );
    options.insert("wake_interval_ms".to_string(), VmValue::Int(250));
    options.insert(
        "watch_paths".to_string(),
        VmValue::List(Rc::new(vec![
            VmValue::String(Rc::from("a.txt")),
            VmValue::String(Rc::from("b.txt")),
        ])),
    );
    options.insert("consolidate_on_idle".to_string(), VmValue::Bool(true));

    let config = parse_daemon_loop_config(Some(&options));
    assert_eq!(config.persist_path.as_deref(), Some("/tmp/daemon.json"));
    assert_eq!(
        config.resume_path.as_deref(),
        Some("/tmp/daemon-resume.json")
    );
    assert_eq!(config.wake_interval_ms, Some(250));
    assert_eq!(config.watch_paths, vec!["a.txt", "b.txt"]);
    assert!(config.consolidate_on_idle);
}