use std::path::Path;
use anyhow::{Context, Result};
use super::turtle::escape_literal;
use crate::iri::ONTOLOGY_NS;
use crate::types::{Chunk, ChunkEdge, ChunkEdgeType, ChunkType};
#[cfg(test)]
mod collision_tests;
mod graph_routing;
#[cfg(test)]
mod graph_tests;
mod remote;
use graph_routing::require_graph_routing;
pub use remote::push_chunks_to_remote_quipu;
pub(crate) use remote::quipu_auth_token;
pub(crate) use crate::iri::chunk_iri;
pub(crate) fn generate_chunk_turtle(chunks: &[Chunk], edges: &[ChunkEdge], repo: &str) -> String {
let mut turtle = String::with_capacity(chunks.len() * 256);
turtle.push_str(&format!("@prefix bobbin: <{ONTOLOGY_NS}> .\n"));
turtle.push_str("@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n");
turtle.push_str("@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n\n");
let mut iri_of: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
let edge_mentions = super::mentions::edge_mention_map(edges);
let mut file_chunks: Vec<&Chunk> = chunks.iter().filter(|c| c.start_line > 0).collect();
file_chunks.sort_by(|a, b| {
a.file_path
.cmp(&b.file_path)
.then(a.start_line.cmp(&b.start_line))
.then(b.end_line.cmp(&a.end_line))
.then(a.id.cmp(&b.id))
});
let mut seen_files = std::collections::HashSet::new();
for chunk in &file_chunks {
if !seen_files.insert(chunk.file_path.as_str()) {
continue;
}
let module = file_entity_iri(repo, chunk);
if matches!(chunk.language.as_str(), "markdown" | "pdf") {
turtle.push_str(&format!("<{module}> a bobbin:Document ;\n"));
turtle.push_str(&format!(
" rdfs:label \"{}\" ;\n bobbin:filePath \"{}\" .\n\n",
escape_literal(&chunk.file_path),
escape_literal(&chunk.file_path)
));
} else {
turtle.push_str(&format!("<{module}> a bobbin:CodeModule ;\n"));
turtle.push_str(&format!(
" rdfs:label \"{}\" ;\n bobbin:filePath \"{}\" ;\n bobbin:repo \"{}\" ;\n bobbin:language \"{}\" .\n\n",
escape_literal(&chunk.file_path),
escape_literal(&chunk.file_path),
escape_literal(repo),
escape_literal(&chunk.language)
));
}
}
let mut order_in_file = 0u32;
let mut prev_file: Option<&str> = None;
let mut claimed_iris: std::collections::HashSet<String> = std::collections::HashSet::new();
for chunk in &file_chunks {
if prev_file != Some(chunk.file_path.as_str()) {
order_in_file = 0;
prev_file = Some(chunk.file_path.as_str());
}
let module = file_entity_iri(repo, chunk);
let label = chunk
.name
.clone()
.unwrap_or_else(|| format!("{}:{}", chunk.file_path, chunk.start_line));
let mut governed_type = match chunk.chunk_type {
ChunkType::Section if chunk.name.is_some() => Some("Section"),
t if t.is_code_symbol() && chunk.name.is_some() => Some("CodeSymbol"),
_ => None,
};
let mut iri = match (governed_type, chunk.name.as_deref()) {
(Some("CodeSymbol"), Some(name)) => {
crate::iri::symbol_iri(repo, &chunk.file_path, name)
}
(Some("Section"), Some(heading)) => {
crate::iri::section_iri(repo, &chunk.file_path, heading)
}
_ => chunk_iri(repo, &chunk.file_path, chunk.start_line),
};
if governed_type.is_some() && !claimed_iris.insert(iri.clone()) {
turtle.push_str(&format!(
"# bobbin: <{iri}> was already claimed by an earlier chunk in this file \
(bobbin has no scope chain, so same-named symbols in one file collide). \
Demoting {}:{} to the chunk lane so no maxCount-1 property is asserted \
twice. See aegis-6noan.\n",
chunk.file_path, chunk.start_line
));
governed_type = None;
iri = chunk_iri(repo, &chunk.file_path, chunk.start_line);
}
if governed_type.is_none() {
let base = iri.clone();
let mut occurrence = 1;
while !claimed_iris.insert(iri.clone()) {
occurrence += 1;
iri = format!("{base}-{occurrence}");
}
}
match governed_type {
Some(kind) => turtle.push_str(&format!("<{iri}> a bobbin:Chunk, bobbin:{kind} ;\n")),
None => turtle.push_str(&format!("<{iri}> a bobbin:Chunk ;\n")),
}
turtle.push_str(&format!(" bobbin:inDocument <{module}> ;\n"));
turtle.push_str(&format!(
" bobbin:chunkOrder \"{order_in_file}\"^^xsd:integer ;\n"
));
turtle.push_str(&format!(
" bobbin:filePath \"{}\" ;\n",
escape_literal(&chunk.file_path)
));
for name in super::mentions::chunk_mention_names(chunk, &edge_mentions) {
turtle.push_str(&format!(
" bobbin:mentions \"{}\" ;\n",
escape_literal(name)
));
}
if let (Some("CodeSymbol"), Some(name)) = (governed_type, chunk.name.as_deref()) {
turtle.push_str(&format!(
" bobbin:name \"{}\" ;\n bobbin:definedIn <{module}> ;\n",
escape_literal(name)
));
if let Some(kind) = governed_symbol_kind(chunk.chunk_type) {
turtle.push_str(&format!(" bobbin:symbolKind \"{kind}\" ;\n"));
}
}
if let (Some("Section"), Some(heading)) = (governed_type, chunk.name.as_deref()) {
let depth = heading.split(" > ").count();
turtle.push_str(&format!(
" bobbin:heading \"{}\" ;\n bobbin:headingDepth \"{depth}\"^^xsd:integer ;\n",
escape_literal(heading)
));
}
turtle.push_str(&format!(
" rdfs:label \"{}\" .\n\n",
escape_literal(&label)
));
iri_of.insert(chunk.id.as_str(), iri);
order_in_file += 1;
}
for edge in edges {
if edge.edge_type != ChunkEdgeType::NextChunk {
continue;
}
let (Some(src), Some(tgt)) = (
iri_of.get(edge.source_chunk.as_str()),
iri_of.get(edge.target_chunk.as_str()),
) else {
continue;
};
turtle.push_str(&format!("<{src}> bobbin:nextChunk <{tgt}> .\n"));
}
turtle
}
fn file_entity_iri(repo: &str, chunk: &Chunk) -> String {
if matches!(chunk.language.as_str(), "markdown" | "pdf") {
crate::iri::document_iri(repo, &chunk.file_path)
} else {
crate::iri::code_module_iri(repo, &chunk.file_path)
}
}
fn governed_symbol_kind(chunk_type: ChunkType) -> Option<&'static str> {
match chunk_type {
ChunkType::Function => Some("function"),
ChunkType::Method => Some("method"),
ChunkType::Class => Some("class"),
ChunkType::Struct => Some("struct"),
ChunkType::Enum => Some("enum"),
ChunkType::Interface => Some("interface"),
ChunkType::Trait | ChunkType::Impl => None,
_ => None,
}
}
pub fn push_chunks_to_quipu(
chunks: &[Chunk],
edges: &[ChunkEdge],
repo_name: &str,
repo_root: &Path,
target_graph: Option<&str>,
) -> Result<(i64, usize)> {
let quipu_config = quipu::QuipuConfig::load(repo_root);
let db_path = if quipu_config.store_path.is_relative() {
repo_root.join(&quipu_config.store_path)
} else {
quipu_config.store_path.clone()
};
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).context("Failed to create quipu store directory")?;
}
let mut store = quipu::Store::open(db_path.to_string_lossy().as_ref())
.map_err(|e| anyhow::anyhow!("Failed to open quipu store: {e}"))?;
let snapshot_key = format!("bobbin-chunks:{repo_name}");
if let Some(graph) = target_graph {
require_graph_routing(&mut store, graph)?;
}
let probe = quipu::tool_knot(
&mut store,
&serde_json::json!({
"turtle": "",
"actor": "bobbin",
"source": "chunk-index-probe",
"replace_snapshot": true,
"snapshot": snapshot_key,
"graph": target_graph,
}),
)
.map_err(|e| anyhow::anyhow!("Quipu snapshot probe failed: {e}"))?;
if probe.get("replaced").and_then(|v| v.as_bool()) != Some(true) {
anyhow::bail!(
"embedded quipu predates snapshot replacement (no 'replaced' in /knot response); \
refusing to push chunks — they would accumulate per index run. \
The pinned quipu (0.3.23, rev 37bfc06a) supports it, so this store \
was opened by something older."
);
}
let turtle = generate_chunk_turtle(chunks, edges, repo_name);
let result = quipu::tool_knot(
&mut store,
&serde_json::json!({
"turtle": turtle,
"timestamp": chrono::Utc::now().to_rfc3339(),
"actor": "bobbin",
"source": "chunk-index",
"replace_snapshot": true,
"snapshot": snapshot_key,
"graph": target_graph,
}),
)
.map_err(|e| anyhow::anyhow!("Failed to push chunks to quipu: {e}"))?;
if result.get("conforms").and_then(|v| v.as_bool()) == Some(false) {
anyhow::bail!("chunk push refused by SHACL validation: {result}");
}
Ok((
result["tx_id"].as_i64().unwrap_or(-1),
result["count"].as_u64().unwrap_or(0) as usize,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ChunkType;
pub(super) fn chunk(id: &str, file: &str, start: u32, name: Option<&str>) -> Chunk {
Chunk {
id: id.to_string(),
file_path: file.to_string(),
chunk_type: ChunkType::Section,
name: name.map(str::to_string),
start_line: start,
end_line: start + 5,
content: "body".to_string(),
language: "markdown".to_string(),
tags: String::new(),
}
}
#[test]
fn turtle_uses_stable_coordinates_not_chunk_ids() {
let chunks = vec![
chunk("hash1", "docs/guide.md", 1, Some("Intro")),
chunk("hash2", "docs/guide.md", 7, Some("Intro > Setup")),
];
let edges = vec![ChunkEdge {
source_chunk: "hash1".to_string(),
target_chunk: "hash2".to_string(),
source_name: "Intro".to_string(),
target_name: "Intro > Setup".to_string(),
edge_type: ChunkEdgeType::NextChunk,
file_path: "docs/guide.md".to_string(),
}];
let turtle = generate_chunk_turtle(&chunks, &edges, "myrepo");
assert!(turtle.contains(
"<http://aegis.gastown.local/ontology/doc/myrepo/docs%2Fguide.md#intro> \
a bobbin:Chunk, bobbin:Section"
));
assert!(turtle.contains(
"<http://aegis.gastown.local/ontology/doc/myrepo/docs%2Fguide.md#intro> \
bobbin:nextChunk \
<http://aegis.gastown.local/ontology/doc/myrepo/docs%2Fguide.md#setup> ."
));
assert!(turtle.contains(
"bobbin:inDocument <http://aegis.gastown.local/ontology/doc/myrepo/docs%2Fguide.md>"
));
assert!(!turtle.contains("http://aegis.gastown.local/code/"));
assert!(!turtle.contains("/C1>"));
assert!(!turtle.contains("hash1"));
}
#[test]
fn turtle_maps_chunks_into_loaded_code_entity_vocabulary() {
let code = Chunk {
id: "fn".into(),
file_path: "src/lib.rs".into(),
chunk_type: ChunkType::Function,
name: Some("parse".into()),
start_line: 10,
end_line: 20,
content: "fn parse() {}".into(),
language: "rust".into(),
tags: String::new(),
};
let section = chunk("section", "docs/guide.md", 3, Some("Guide > Setup"));
let turtle = generate_chunk_turtle(&[code, section], &[], "repo");
assert!(turtle.contains(
"<http://aegis.gastown.local/ontology/code/repo/src%2Flib.rs> a bobbin:CodeModule"
));
assert!(turtle.contains("bobbin:repo \"repo\""));
assert!(turtle.contains("bobbin:language \"rust\""));
assert!(turtle.contains(
"<http://aegis.gastown.local/ontology/code/repo/src%2Flib.rs::parse> \
a bobbin:Chunk, bobbin:CodeSymbol"
));
assert!(turtle.contains("bobbin:name \"parse\""));
assert!(turtle.contains("bobbin:symbolKind \"function\""));
assert!(turtle.contains(
"<http://aegis.gastown.local/ontology/doc/repo/docs%2Fguide.md> a bobbin:Document"
));
assert!(turtle.contains(
"<http://aegis.gastown.local/ontology/doc/repo/docs%2Fguide.md#setup> \
a bobbin:Chunk, bobbin:Section"
));
assert!(turtle.contains("bobbin:heading \"Guide > Setup\""));
assert!(turtle.contains("bobbin:headingDepth \"2\"^^xsd:integer"));
}
#[test]
fn synthetic_line_zero_chunks_are_skipped() {
let chunks = vec![chunk("c", "beads:rig:x", 0, None)];
let turtle = generate_chunk_turtle(&chunks, &[], "r");
assert!(!turtle.contains("beads"));
}
#[test]
fn snapshot_push_replaces_instead_of_accumulating() {
let dir = tempfile::tempdir().unwrap();
let two = vec![
chunk("h1", "docs/guide.md", 1, Some("Intro")),
chunk("h2", "docs/guide.md", 7, Some("Setup")),
];
let (tx1, n1) = push_chunks_to_quipu(&two, &[], "r", dir.path(), None).expect("first push");
assert!(tx1 > 0);
assert!(n1 > 0);
let one = vec![chunk("h1", "docs/guide.md", 1, Some("Intro"))];
push_chunks_to_quipu(&one, &[], "r", dir.path(), None).expect("second push");
let store = quipu::Store::open(dir.path().join(".bobbin/quipu/quipu.db").to_str().unwrap())
.unwrap();
let gone = crate::iri::section_iri("r", "docs/guide.md", "Setup");
let facts = store.current_facts().unwrap();
if let Some(id) = store.lookup(&gone).unwrap() {
assert!(
!facts.iter().any(|f| f.entity == id),
"vanished chunk C7 must have no live facts after the replace"
);
}
let kept = store
.lookup(&crate::iri::section_iri("r", "docs/guide.md", "Intro"))
.unwrap();
let kept = kept.expect("surviving chunk stays interned");
assert!(facts.iter().any(|f| f.entity == kept));
}
#[test]
fn order_restarts_per_file() {
let chunks = vec![
chunk("a", "a.md", 1, None),
chunk("b", "a.md", 9, None),
chunk("c", "b.md", 3, None),
];
let turtle = generate_chunk_turtle(&chunks, &[], "r");
let c_block = turtle.split("b.md#C3").nth(1).unwrap();
assert!(c_block.contains("chunkOrder \"0\""));
}
}