openhawk-core 0.1.0

Core runtime for OpenHawk Agent OS — agent lifecycle, orchestration, config, healing
Documentation
use rusqlite::Connection;

use crate::db::init_database;
use crate::pattern_detector::PatternDetector;

fn in_memory_detector(retention_days: u32) -> PatternDetector {
    let conn = Connection::open_in_memory().unwrap();
    conn.execute_batch(crate::db::SCHEMA).unwrap();
    PatternDetector::new(conn, retention_days)
}

fn detector_with_db_file(retention_days: u32) -> (tempfile::NamedTempFile, PatternDetector) {
    let f = tempfile::NamedTempFile::new().unwrap();
    let conn = init_database(f.path()).unwrap();
    (f, PatternDetector::new(conn, retention_days))
}

#[test]
fn record_action_stores_sequence_in_order() {
    let mut d = in_memory_detector(90);
    d.record_action("open file");
    d.record_action("edit file");
    d.record_action("save file");
    for _ in 0..4 {
        d.record_action("open file");
        d.record_action("edit file");
        d.record_action("save file");
    }
    let patterns = d.detect_patterns();
    assert!(
        patterns.iter().any(|p| p.action_sequence == vec!["open file", "edit file", "save file"]),
        "expected the 3-action sequence to be detected"
    );
}

#[test]
fn detect_patterns_requires_min_3_actions() {
    let mut d = in_memory_detector(90);
    for _ in 0..10 {
        d.record_action("a");
        d.record_action("b");
    }
    let patterns = d.detect_patterns();
    assert!(patterns.iter().all(|p| p.action_sequence.len() >= 3));
}

#[test]
fn detect_patterns_requires_min_5_occurrences() {
    let mut d = in_memory_detector(90);
    for _ in 0..4 {
        d.record_action("x");
        d.record_action("y");
        d.record_action("z");
    }
    let patterns = d.detect_patterns();
    assert!(patterns.is_empty(), "4 occurrences should not trigger detection");
}

#[test]
fn detect_patterns_triggers_at_exactly_5_occurrences() {
    let mut d = in_memory_detector(90);
    for _ in 0..5 {
        d.record_action("hawk run agent");
        d.record_action("hawk verify session");
        d.record_action("hawk undo");
    }
    let patterns = d.detect_patterns();
    assert!(patterns.iter().any(|p| p.occurrence_count >= 5));
}

#[test]
fn detect_patterns_persists_to_sqlite() {
    let (_f, mut d) = detector_with_db_file(90);
    for _ in 0..5 {
        d.record_action("git add .");
        d.record_action("git commit");
        d.record_action("git push");
    }
    d.detect_patterns();
    let records = d.list_patterns().unwrap();
    assert!(!records.is_empty());
}

#[test]
fn detect_patterns_upserts_on_second_call() {
    let (_f, mut d) = detector_with_db_file(90);
    for _ in 0..5 {
        d.record_action("a");
        d.record_action("b");
        d.record_action("c");
    }
    d.detect_patterns();
    for _ in 0..3 {
        d.record_action("a");
        d.record_action("b");
        d.record_action("c");
    }
    d.detect_patterns();
    let records = d.list_patterns().unwrap();
    let matching: Vec<_> = records.iter().filter(|r| r.action_sequence == vec!["a", "b", "c"]).collect();
    assert_eq!(matching.len(), 1, "same sequence must not create duplicate rows");
    assert!(matching[0].occurrence_count >= 5);
}

#[test]
fn accept_pattern_returns_valid_toml_manifest() {
    let (_f, mut d) = detector_with_db_file(90);
    for _ in 0..5 {
        d.record_action("open browser");
        d.record_action("navigate to url");
        d.record_action("extract data");
    }
    let patterns = d.detect_patterns();
    let id = &patterns[0].id;
    let manifest = d.accept_pattern(id).unwrap();
    assert!(manifest.contains("[agent]"));
    assert!(manifest.contains("name ="));
    assert!(manifest.contains("[permissions]"));
    assert!(manifest.contains("[pattern]"));
}

#[test]
fn accept_pattern_updates_status_to_accepted() {
    let (_f, mut d) = detector_with_db_file(90);
    for _ in 0..5 {
        d.record_action("step1");
        d.record_action("step2");
        d.record_action("step3");
    }
    let patterns = d.detect_patterns();
    let id = patterns[0].id.clone();
    d.accept_pattern(&id).unwrap();
    let records = d.list_patterns().unwrap();
    let rec = records.iter().find(|r| r.id == id).unwrap();
    assert_eq!(rec.status, "Accepted");
}

#[test]
fn accept_pattern_returns_error_for_unknown_id() {
    let d = in_memory_detector(90);
    assert!(d.accept_pattern("nonexistent-id").is_err());
}

#[test]
fn decline_pattern_updates_status_to_declined() {
    let (_f, mut d) = detector_with_db_file(90);
    for _ in 0..5 {
        d.record_action("cmd1");
        d.record_action("cmd2");
        d.record_action("cmd3");
    }
    let patterns = d.detect_patterns();
    let id = patterns[0].id.clone();
    d.decline_pattern(&id).unwrap();
    let records = d.list_patterns().unwrap();
    let rec = records.iter().find(|r| r.id == id).unwrap();
    assert_eq!(rec.status, "Declined");
}

#[test]
fn decline_pattern_returns_error_for_unknown_id() {
    let d = in_memory_detector(90);
    assert!(d.decline_pattern("no-such-id").is_err());
}

#[test]
fn reset_declined_re_enables_all_declined_patterns() {
    let (_f, mut d) = detector_with_db_file(90);
    for _ in 0..5 {
        d.record_action("p1a");
        d.record_action("p1b");
        d.record_action("p1c");
    }
    for _ in 0..5 {
        d.record_action("p2a");
        d.record_action("p2b");
        d.record_action("p2c");
    }
    let patterns = d.detect_patterns();
    assert!(patterns.len() >= 2);
    for p in &patterns {
        d.decline_pattern(&p.id).unwrap();
    }
    let reset_count = d.reset_declined().unwrap();
    assert!(reset_count >= 2);
    let records = d.list_patterns().unwrap();
    assert!(records.iter().all(|r| r.status != "Declined"));
}

#[test]
fn reset_declined_returns_zero_when_nothing_declined() {
    let d = in_memory_detector(90);
    assert_eq!(d.reset_declined().unwrap(), 0);
}

#[test]
fn cleanup_expired_removes_past_expiry_rows() {
    let conn = Connection::open_in_memory().unwrap();
    conn.execute_batch(crate::db::SCHEMA).unwrap();
    let yesterday = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339();
    let now = chrono::Utc::now().to_rfc3339();
    conn.execute(
        "INSERT INTO patterns (id, action_sequence, occurrence_count, last_occurrence, status, created_at, expires_at) \
         VALUES ('expired-id', '[\"a\",\"b\",\"c\"]', 5, ?1, 'Detected', ?2, ?3)",
        rusqlite::params![now, now, yesterday],
    ).unwrap();
    let d = PatternDetector::new(conn, 90);
    let deleted = d.cleanup_expired().unwrap();
    assert_eq!(deleted, 1);
    assert!(d.list_patterns().unwrap().iter().all(|r| r.id != "expired-id"));
}

#[test]
fn cleanup_expired_keeps_non_expired_rows() {
    let (_f, mut d) = detector_with_db_file(90);
    for _ in 0..5 {
        d.record_action("keep1");
        d.record_action("keep2");
        d.record_action("keep3");
    }
    d.detect_patterns();
    let deleted = d.cleanup_expired().unwrap();
    assert_eq!(deleted, 0);
    assert!(!d.list_patterns().unwrap().is_empty());
}

#[test]
fn list_patterns_returns_empty_when_no_patterns() {
    let d = in_memory_detector(90);
    assert!(d.list_patterns().unwrap().is_empty());
}

#[test]
fn list_patterns_includes_all_fields() {
    let (_f, mut d) = detector_with_db_file(90);
    for _ in 0..5 {
        d.record_action("hawk run");
        d.record_action("hawk ps");
        d.record_action("hawk stop 1");
    }
    d.detect_patterns();
    let records = d.list_patterns().unwrap();
    assert!(!records.is_empty());
    let r = &records[0];
    assert!(!r.id.is_empty());
    assert!(!r.action_sequence.is_empty());
    assert!(r.occurrence_count >= 5);
    assert!(!r.last_occurrence.is_empty());
    assert!(!r.status.is_empty());
}