relay-knowledge 1.1.17

Graph-database-based knowledge graph project.
Documentation
//! Cross-owner file and content retirement contracts.

use std::{collections::BTreeSet, time::Instant};

use rusqlite::Connection;

use crate::storage::{
    FileContentChunk, FileContentEntry, FileContentSearchRequest, FileIndexEntry, FileIndexRoot,
    FileIndexRootUpdate, FileSearchRequest,
};

use super::super::{
    content::{cursors, search as search_content},
    diagnostics, initialize_schema, mark_unconfigured_roots, replace_root, search,
};

#[test]
fn retiring_roots_clears_content_diagnostics() {
    let mut connection = open_connection();
    replace_root(
        &mut connection,
        FileIndexRootUpdate {
            root: root(),
            entries: vec![entry("/workspace/docs/retired.md", "docs/retired.md")],
            processed_content_paths: processed_paths(["/workspace/docs/retired.md"]),
            content_entries: vec![content_entry(
                "/workspace/docs/retired.md",
                "docs/retired.md",
                "retired content",
                10,
            )],
            scan_error_count: 0,
            truncated: false,
            content_truncated: false,
            content_read_error_count: 0,
            last_error: None,
            now_ms: 10,
        },
    )
    .expect("root with content should be indexed");

    let initial = diagnostics(&connection).expect("diagnostics should load");
    assert_eq!(initial.indexed_content_count, 1);
    assert_eq!(initial.stale_content_cursor_count, 0);
    assert!(initial.content_cursors.is_empty());
    assert!(
        !cursors(&connection)
            .expect("cursors should load")
            .is_empty()
    );

    let retired = mark_unconfigured_roots(&mut connection, Vec::new(), 20)
        .expect("unconfigured roots should be marked");
    assert_eq!(retired.indexed_content_count, 0);
    assert_eq!(retired.skipped_content_count, 0);
    assert_eq!(retired.unchanged_content_count, 0);
    assert_eq!(retired.stale_content_cursor_count, 0);

    let refreshed = diagnostics(&connection).expect("diagnostics should reload");
    assert_eq!(refreshed.indexed_content_count, 0);
    assert_eq!(refreshed.stale_content_cursor_count, 0);
    assert!(refreshed.content_cursors.is_empty());
    assert!(
        cursors(&connection)
            .expect("cursors should reload")
            .is_empty()
    );
}

#[test]
fn content_truncated_scan_still_retires_unobserved_file_paths() {
    let mut connection = open_connection();
    replace_root(
        &mut connection,
        FileIndexRootUpdate {
            root: root(),
            entries: vec![
                entry("/workspace/docs/keep.md", "docs/keep.md"),
                entry("/workspace/docs/deleted.md", "docs/deleted.md"),
            ],
            processed_content_paths: processed_paths([
                "/workspace/docs/keep.md",
                "/workspace/docs/deleted.md",
            ]),
            content_entries: vec![
                content_entry(
                    "/workspace/docs/keep.md",
                    "docs/keep.md",
                    "keep content",
                    10,
                ),
                content_entry(
                    "/workspace/docs/deleted.md",
                    "docs/deleted.md",
                    "deleted content",
                    10,
                ),
            ],
            scan_error_count: 0,
            truncated: false,
            content_truncated: false,
            content_read_error_count: 0,
            last_error: None,
            now_ms: 10,
        },
    )
    .expect("initial root should be indexed");

    let status = replace_root(
        &mut connection,
        FileIndexRootUpdate {
            root: root(),
            entries: vec![entry("/workspace/docs/keep.md", "docs/keep.md")],
            processed_content_paths: BTreeSet::new(),
            content_entries: Vec::new(),
            scan_error_count: 0,
            truncated: false,
            content_truncated: true,
            content_read_error_count: 0,
            last_error: Some("file content scan byte budget exceeded".to_owned()),
            now_ms: 20,
        },
    )
    .expect("content-truncated root should update");
    assert!(!status.truncated);
    assert!(status.content_truncated);
    assert_eq!(status.missing_file_count, 1);

    let deleted_hits = search(
        &connection,
        FileSearchRequest {
            query: "deleted".to_owned(),
            source_scope: Some("local-files".to_owned()),
            root_id: Some("root-a".to_owned()),
            limit: 5,
            timeout_ms: 750,
        },
        deadline(),
    )
    .expect("search should run");
    assert!(deleted_hits.is_empty());

    let deleted_content_hits = search_content(
        &connection,
        FileContentSearchRequest {
            query: "deleted".to_owned(),
            source_scope: Some("local-files".to_owned()),
            root_id: Some("root-a".to_owned()),
            authorized_roots: vec![root()],
            limit: 5,
            timeout_ms: 750,
        },
        deadline(),
    )
    .expect("content search should run");
    assert!(deleted_content_hits.is_empty());

    let keep_content_hits = search_content(
        &connection,
        FileContentSearchRequest {
            query: "keep".to_owned(),
            source_scope: Some("local-files".to_owned()),
            root_id: Some("root-a".to_owned()),
            authorized_roots: vec![root()],
            limit: 5,
            timeout_ms: 750,
        },
        deadline(),
    )
    .expect("content search should run");
    assert_eq!(keep_content_hits.len(), 1);
    assert_eq!(keep_content_hits[0].path, "/workspace/docs/keep.md");
}

#[test]
fn full_content_scan_retires_cursors_for_missing_files() {
    let mut connection = open_connection();
    replace_root(
        &mut connection,
        FileIndexRootUpdate {
            root: root(),
            entries: vec![
                entry("/workspace/docs/keep.md", "docs/keep.md"),
                entry("/workspace/docs/deleted.md", "docs/deleted.md"),
            ],
            processed_content_paths: processed_paths([
                "/workspace/docs/keep.md",
                "/workspace/docs/deleted.md",
            ]),
            content_entries: vec![
                content_entry(
                    "/workspace/docs/keep.md",
                    "docs/keep.md",
                    "keep content",
                    10,
                ),
                content_entry(
                    "/workspace/docs/deleted.md",
                    "docs/deleted.md",
                    "deleted content",
                    10,
                ),
            ],
            scan_error_count: 0,
            truncated: false,
            content_truncated: false,
            content_read_error_count: 0,
            last_error: None,
            now_ms: 10,
        },
    )
    .expect("initial content should be indexed");
    let initial = cursors(&connection).expect("cursors should load");
    assert!(
        initial
            .iter()
            .any(|cursor| cursor.path == "/workspace/docs/deleted.md")
    );

    replace_root(
        &mut connection,
        FileIndexRootUpdate {
            root: root(),
            entries: vec![entry("/workspace/docs/keep.md", "docs/keep.md")],
            processed_content_paths: processed_paths(["/workspace/docs/keep.md"]),
            content_entries: vec![content_entry(
                "/workspace/docs/keep.md",
                "docs/keep.md",
                "keep content",
                10,
            )],
            scan_error_count: 0,
            truncated: false,
            content_truncated: false,
            content_read_error_count: 0,
            last_error: None,
            now_ms: 20,
        },
    )
    .expect("full rescan should retire missing content");

    let refreshed_cursors = cursors(&connection).expect("cursors should reload");
    assert!(
        refreshed_cursors
            .iter()
            .all(|cursor| cursor.path != "/workspace/docs/deleted.md")
    );
    let refreshed = diagnostics(&connection).expect("diagnostics should reload");
    assert_eq!(refreshed.stale_content_cursor_count, 0);
    assert!(refreshed.content_cursors.is_empty());
}

fn open_connection() -> Connection {
    let connection = Connection::open_in_memory().expect("connection should open");
    initialize_schema(&connection).expect("schema should initialize");
    connection
}

fn deadline() -> Instant {
    Instant::now() + std::time::Duration::from_millis(750)
}

fn root() -> FileIndexRoot {
    FileIndexRoot {
        scope_id: "local-files".to_owned(),
        root_id: "root-a".to_owned(),
        root_path: "/workspace".to_owned(),
    }
}

fn entry(path: &str, relative_path: &str) -> FileIndexEntry {
    FileIndexEntry {
        scope_id: "local-files".to_owned(),
        root_id: "root-a".to_owned(),
        path: path.to_owned(),
        relative_path: relative_path.to_owned(),
        file_name: path.rsplit('/').next().expect("file name").to_owned(),
        extension: Some("md".to_owned()),
        parent_dir: "/workspace/docs".to_owned(),
        size_bytes: 128,
        modified_at_ms: 1,
        fingerprint: "128:1".to_owned(),
    }
}

fn content_entry(path: &str, relative_path: &str, content: &str, now_ms: u64) -> FileContentEntry {
    FileContentEntry {
        scope_id: "local-files".to_owned(),
        root_id: "root-a".to_owned(),
        path: path.to_owned(),
        relative_path: relative_path.to_owned(),
        fingerprint: "128:1".to_owned(),
        content_hash: format!("content:{now_ms}"),
        indexed_at_ms: now_ms,
        graph_version: 1,
        chunks: vec![FileContentChunk {
            chunk_index: 0,
            start_byte: 0,
            end_byte: u32::try_from(content.len()).expect("content fits u32"),
            start_line: 1,
            end_line: 1,
            content: content.to_owned(),
        }],
        skipped_reason: None,
    }
}

fn processed_paths<const N: usize>(paths: [&str; N]) -> BTreeSet<String> {
    paths.into_iter().map(str::to_owned).collect()
}