cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! `QueryStore` adapter backed by `.cozo` files under `<query_dir>/`.
//! Reads each body and decodes the optional `# description: ...`
//! header so the domain receives a ready-to-use `QueryDescription`.

use std::fs;
use std::path::PathBuf;

use crate::domain::model::query::{Queries, Query, QueryDescription, QueryIdentifier, QueryScript};
use crate::domain::usecases::query::QueryStore;

pub struct CozoFileStore {
    pub dir: PathBuf,
}

impl CozoFileStore {
    pub fn new(dir: PathBuf) -> Self {
        Self { dir }
    }
}

impl QueryStore for CozoFileStore {
    fn list(&self) -> anyhow::Result<Queries> {
        let entries = match fs::read_dir(&self.dir) {
            Ok(e) => e,
            Err(_) => return Ok(Queries::new()),
        };
        let mut out: Vec<Query> = Vec::new();
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|s| s.to_str()) != Some("cozo") {
                continue;
            }
            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
                continue;
            };
            let Ok(name) = QueryIdentifier::new(stem) else {
                continue;
            };
            let Ok(body) = fs::read_to_string(&path) else {
                continue;
            };
            let description = extract_description(&body).map(QueryDescription::new);
            out.push(Query::new(name, QueryScript::new(body), description));
        }
        Ok(out.into_iter().collect())
    }
}

/// Scan the first ten lines for `# description: ...` and return its
/// trimmed payload. Leading whitespace before `#` is tolerated; anything
/// past line ten is ignored. Cozo-specific — knowledge of the surface
/// format lives in the adapter, not the domain.
fn extract_description(body: &str) -> Option<String> {
    for line in body.lines().take(10) {
        let trimmed = line.trim_start();
        if let Some(rest) = trimmed.strip_prefix("# description:") {
            return Some(rest.trim().to_owned());
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn store_at(dir: &TempDir) -> CozoFileStore {
        CozoFileStore::new(dir.path().to_path_buf())
    }

    fn name(s: &str) -> QueryIdentifier {
        QueryIdentifier::new(s).unwrap()
    }

    #[test]
    fn list_empty_dir_yields_empty_collection() {
        let dir = TempDir::new().unwrap();
        assert!(store_at(&dir).list().unwrap().is_empty());
    }

    #[test]
    fn list_missing_dir_yields_empty_collection_without_error() {
        let dir = TempDir::new().unwrap();
        let store = CozoFileStore::new(dir.path().join("does-not-exist"));
        assert!(store.list().unwrap().is_empty());
    }

    #[test]
    fn list_returns_each_cozo_file_with_its_script_body() {
        let dir = TempDir::new().unwrap();
        let alpha_body = "# description: alpha doc\n?[x] := x = 1\n";
        let bravo_body = "?[x] := x = 2\n";
        let charlie_body = "\n# description: charlie doc\n";
        fs::write(dir.path().join("alpha.cozo"), alpha_body).unwrap();
        fs::write(dir.path().join("bravo.cozo"), bravo_body).unwrap();
        fs::write(dir.path().join("charlie.cozo"), charlie_body).unwrap();
        fs::write(dir.path().join("ignored.txt"), "not a query\n").unwrap();

        let qs = store_at(&dir).list().unwrap();
        let alpha = qs.get(&name("alpha")).unwrap();
        let bravo = qs.get(&name("bravo")).unwrap();
        let charlie = qs.get(&name("charlie")).unwrap();
        assert_eq!(alpha.script.as_str(), alpha_body);
        assert_eq!(
            alpha.description.as_ref().map(|d| d.as_str()),
            Some("alpha doc")
        );
        assert_eq!(bravo.script.as_str(), bravo_body);
        assert!(bravo.description.is_none());
        assert_eq!(
            charlie.description.as_ref().map(|d| d.as_str()),
            Some("charlie doc")
        );
        assert!(qs.get(&name("ignored")).is_none());
    }

    #[test]
    fn files_with_invalid_names_are_silently_skipped() {
        let dir = TempDir::new().unwrap();
        fs::write(dir.path().join("Mixed-Case.cozo"), "?[x] := x = 1\n").unwrap();
        fs::write(dir.path().join("ok.cozo"), "?[x] := x = 1\n").unwrap();
        let qs = store_at(&dir).list().unwrap();
        assert!(qs.get(&name("ok")).is_some());
        assert_eq!(qs.len(), 1);
    }

    #[test]
    fn description_on_fifth_line_is_extracted() {
        assert_eq!(
            extract_description("\n\n\n\n# description: late finding\n"),
            Some("late finding".to_owned())
        );
    }

    #[test]
    fn description_after_line_ten_is_ignored() {
        let mut body = String::new();
        for _ in 0..10 {
            body.push('\n');
        }
        body.push_str("# description: too late\n");
        assert_eq!(extract_description(&body), None);
    }

    #[test]
    fn description_tolerates_leading_whitespace() {
        assert_eq!(
            extract_description("    # description:   spaced out  \n"),
            Some("spaced out".to_owned())
        );
    }

    #[test]
    fn description_picks_first_matching_comment() {
        assert_eq!(
            extract_description("# not a description\n# description: real one\n"),
            Some("real one".to_owned())
        );
    }
}