codescout 0.13.0

High-performance coding agent toolkit MCP server
Documentation
use anyhow::Result;
use rusqlite::params;
use serde::{Deserialize, Serialize};

use super::Catalog;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LinkRow {
    pub src_id: String,
    pub dst_id: String,
    pub rel: String,
    pub created_at: i64,
}

pub fn insert(cat: &Catalog, link: &LinkRow) -> Result<()> {
    insert_with(&cat.conn, link)
}

/// Insert into an existing connection or transaction. Use this when the
/// caller wants atomicity across multiple writes.
pub fn insert_with(conn: &rusqlite::Connection, link: &LinkRow) -> Result<()> {
    conn.execute(
        "INSERT OR IGNORE INTO artifact_link (src_id, dst_id, rel, created_at) VALUES (?, ?, ?, ?)",
        params![link.src_id, link.dst_id, link.rel, link.created_at],
    )?;
    Ok(())
}

pub fn outgoing(cat: &Catalog, src_id: &str) -> Result<Vec<LinkRow>> {
    collect(cat, "WHERE src_id = ?1", params![src_id])
}

pub fn incoming(cat: &Catalog, dst_id: &str) -> Result<Vec<LinkRow>> {
    collect(cat, "WHERE dst_id = ?1", params![dst_id])
}

fn collect(cat: &Catalog, where_clause: &str, p: impl rusqlite::Params) -> Result<Vec<LinkRow>> {
    let sql = format!("SELECT src_id, dst_id, rel, created_at FROM artifact_link {where_clause}");
    let mut stmt = cat.conn.prepare(&sql)?;
    let rows = stmt.query_map(p, |r| {
        Ok(LinkRow {
            src_id: r.get(0)?,
            dst_id: r.get(1)?,
            rel: r.get(2)?,
            created_at: r.get(3)?,
        })
    })?;
    rows.collect::<Result<_, _>>().map_err(Into::into)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::librarian::catalog::artifact;
    use crate::librarian::catalog::artifact::ArtifactRow;

    fn art(id: &str) -> ArtifactRow {
        ArtifactRow {
            id: id.into(),
            abs_path: std::path::PathBuf::from(format!("/test/r/{id}.md")),
            kind: "spec".into(),
            status: "active".into(),
            title: None,
            owners: vec![],
            tags: vec![],
            topic: None,
            time_scope: None,
            source: None,
            created_at: 0,
            updated_at: 0,
            file_mtime: 0,
            file_sha256: "".into(),
            confidence: 1.0,
        }
    }

    #[test]
    fn insert_and_query_links() {
        let cat = Catalog::open_in_memory().unwrap();
        artifact::upsert(&cat, &art("a")).unwrap();
        artifact::upsert(&cat, &art("b")).unwrap();
        insert(
            &cat,
            &LinkRow {
                src_id: "a".into(),
                dst_id: "b".into(),
                rel: "supersedes".into(),
                created_at: 1,
            },
        )
        .unwrap();
        let out = outgoing(&cat, "a").unwrap();
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].dst_id, "b");
        let inc = incoming(&cat, "b").unwrap();
        assert_eq!(inc.len(), 1);
    }

    #[test]
    fn cascade_delete_removes_links() {
        let cat = Catalog::open_in_memory().unwrap();
        artifact::upsert(&cat, &art("a")).unwrap();
        artifact::upsert(&cat, &art("b")).unwrap();
        insert(
            &cat,
            &LinkRow {
                src_id: "a".into(),
                dst_id: "b".into(),
                rel: "implements".into(),
                created_at: 1,
            },
        )
        .unwrap();
        artifact::delete(&cat, "a").unwrap();
        assert!(outgoing(&cat, "a").unwrap().is_empty());
        assert!(incoming(&cat, "b").unwrap().is_empty());
    }
}