use crate::error::{Error, Result};
use crate::rest::ValueDto;
use crate::service::triples::{delete_triple, insert_triple_inner};
use crate::state::AppState;
use aingle_graph::{NodeId, Predicate, TriplePattern};
use aingle_ingest::{extract, ObjectValue};
use ineru::{MemoryEntry, MemoryId, MemoryMetadata};
use aingle_graph::Error as GraphError;
pub const PRED_SOURCE_HASH: &str = "aingle:source_hash";
pub const CHUNK_ENTRY_TYPE: &str = "doc_chunk";
const INGEST_EXTENSIONS: &[&str] = &[
"md",
"markdown",
"mdx",
"txt",
"rst",
"org",
"adoc", "toml",
"json",
"jsonc",
"yaml",
"yml",
"xml",
"ini",
"cfg",
"conf",
"gradle",
"properties",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"rs",
"ts",
"tsx",
"js",
"jsx",
"mjs",
"cjs",
"py",
"go",
"swift",
"kt",
"kts",
"java",
"c",
"h",
"cc",
"cpp",
"cxx",
"hpp",
"hh",
"cs",
"rb",
"php",
"scala",
"sh",
"bash",
"zsh",
"lua",
"dart",
"r",
"m",
"mm",
"vue",
"svelte",
"sql",
];
const INGEST_FILENAMES: &[&str] = &[
"dockerfile",
"makefile",
"readme",
"license",
"cmakelists.txt",
"gemfile",
"rakefile",
"procfile",
"vagrantfile",
];
fn is_ingestable_file(path: &std::path::Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
let lower = name.to_ascii_lowercase();
if lower.ends_with(".min.js")
|| lower.ends_with(".min.css")
|| lower.ends_with(".map")
|| lower.ends_with(".lock")
|| lower.contains(".generated.")
|| matches!(lower.as_str(), "package-lock.json" | "pnpm-lock.yaml")
{
return false;
}
if INGEST_FILENAMES.contains(&lower.as_str()) {
return true;
}
match path.extension().and_then(|e| e.to_str()) {
Some(ext) => INGEST_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()),
None => false,
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SourceRecord {
pub path: String,
pub content_hash: String,
}
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct IngestReport {
pub files_seen: usize,
pub files_ingested: usize,
pub files_skipped: usize,
pub triples_written: usize,
pub chunks_written: usize,
pub sources: Vec<SourceRecord>,
}
pub async fn ingest_path(
state: &AppState,
root_path: &str,
namespace: Option<String>,
) -> Result<IngestReport> {
ingest_path_with_progress(state, root_path, namespace, None).await
}
pub type IngestProgress<'a> = &'a (dyn Fn(usize, usize) + Sync);
pub async fn ingest_path_with_progress(
state: &AppState,
root_path: &str,
namespace: Option<String>,
on_progress: Option<IngestProgress<'_>>,
) -> Result<IngestReport> {
let mut report = IngestReport::default();
if !std::path::Path::new(root_path).exists() {
tracing::warn!(
path = %root_path,
"ingest root does not exist yet; starting empty (engine stays up)"
);
return Ok(report);
}
let walker = ignore::WalkBuilder::new(root_path)
.hidden(false)
.git_ignore(true)
.build();
let mut files: Vec<(String, String)> = Vec::new();
for entry in walker {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
tracing::warn!("skipping unreadable path during ingest walk: {e}");
continue;
}
};
let path = entry.path();
if !path.is_file() {
continue;
}
if let Ok(rel) = path.strip_prefix(root_path) {
if rel
.components()
.next()
.is_some_and(|c| c.as_os_str() == "_inbox")
{
continue;
}
}
if !is_ingestable_file(path) {
continue;
}
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) => {
tracing::warn!(
"skipping unreadable file during ingest: {}: {e}",
path.display()
);
continue;
}
};
report.files_seen += 1;
let rel_path = path
.strip_prefix(root_path)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
files.push((rel_path, content));
}
files.sort_by_key(|(_, content)| content.len());
let total = files.len();
for (idx, (rel_path, content)) in files.into_iter().enumerate() {
if let Some(cb) = on_progress {
cb(idx, total);
}
let content_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
let existing_hash = {
let graph = state.graph.read().await;
let pattern = TriplePattern::any()
.with_subject(NodeId::named(&rel_path))
.with_predicate(Predicate::named(PRED_SOURCE_HASH));
graph
.find(pattern)
.map_err(|e| Error::Internal(format!("graph find error: {e}")))?
.into_iter()
.next()
.and_then(|t| t.object_string().map(|s| s.to_string()))
};
if let Some(ref existing) = existing_hash {
if existing == &content_hash {
report.files_skipped += 1;
continue;
}
}
report.files_ingested += 1;
if existing_hash.is_some() {
purge_source(state, &rel_path, namespace.clone()).await?;
}
let extraction = extract(&rel_path, &content);
for pt in &extraction.triples {
let object_dto = match &pt.object {
ObjectValue::Node(n) => ValueDto::Node { node: n.clone() },
ObjectValue::Text(t) => ValueDto::String(t.clone()),
};
#[cfg(feature = "dag")]
let prov = Some(pt.provenance.clone());
#[cfg(not(feature = "dag"))]
let _prov = ();
let result = insert_triple_inner(
state,
object_dto,
&pt.subject,
&pt.predicate,
#[cfg(feature = "dag")]
prov,
#[cfg(not(feature = "dag"))]
None,
namespace.clone(),
None,
)
.await;
match result {
Ok(_) => {
report.triples_written += 1;
}
Err(Error::GraphError(GraphError::Duplicate(_))) => {
report.triples_written += 1;
}
Err(e) => {
return Err(Error::Internal(format!("triple insert error: {e}")));
}
}
}
if !extraction.chunks.is_empty() {
let texts: Vec<String> = extraction.chunks.iter().map(|c| c.text.clone()).collect();
let embeddings = state.embedder.embed_passages(&texts);
let mut mem = state.memory.write().await;
for (chunk, embedding) in extraction.chunks.iter().zip(embeddings) {
let mut entry = MemoryEntry::new(
CHUNK_ENTRY_TYPE,
serde_json::json!({
"text": chunk.text,
"source_path": chunk.provenance.source_path,
"line_start": chunk.provenance.line_start,
"line_end": chunk.provenance.line_end,
"content_hash": chunk.provenance.content_hash,
}),
);
entry.metadata = MemoryMetadata::with_source(&chunk.provenance.source_path);
entry.metadata.importance = 0.6;
entry.embedding = Some(embedding);
mem.remember(entry)
.map_err(|e| Error::Internal(format!("memory write error: {e}")))?;
report.chunks_written += 1;
}
}
#[cfg(feature = "dag")]
let registry_prov = Some(aingle_graph::dag::Provenance {
source_path: rel_path.clone(),
line_start: 0,
line_end: 0,
content_hash: content_hash.clone(),
});
#[cfg(not(feature = "dag"))]
let _registry_prov = ();
insert_triple_inner(
state,
ValueDto::String(content_hash.clone()),
&rel_path,
PRED_SOURCE_HASH,
#[cfg(feature = "dag")]
registry_prov,
#[cfg(not(feature = "dag"))]
None,
namespace.clone(),
None,
)
.await
.map_err(|e| Error::Internal(format!("registry triple insert error: {e}")))?;
report.triples_written += 1;
report.sources.push(SourceRecord {
path: rel_path.clone(),
content_hash: content_hash.clone(),
});
}
let _ = crate::service::git_provenance::record_git_provenance(
state,
root_path,
report.files_ingested,
)
.await;
if let Some(cb) = on_progress {
cb(total, total);
}
Ok(report)
}
async fn purge_source(state: &AppState, rel_path: &str, namespace: Option<String>) -> Result<()> {
let stale_ids: Vec<String> = {
let graph = state.graph.read().await;
let pattern = TriplePattern::any().with_subject(NodeId::named(rel_path));
graph
.find(pattern)
.map_err(|e| Error::Internal(format!("graph find error: {e}")))?
.into_iter()
.map(|t| t.id().to_hex())
.collect()
};
for hex_id in stale_ids {
let _ = delete_triple(state, &hex_id, namespace.clone(), None).await;
}
{
let mut mem = state.memory.write().await;
let ids: Vec<MemoryId> = mem
.stm
.all_entries()
.into_iter()
.chain(mem.ltm.all_entries())
.filter(|e| e.entry_type == CHUNK_ENTRY_TYPE && e.metadata.source == rel_path)
.map(|e| e.id)
.collect();
for id in ids {
let _ = mem.forget(&id);
}
}
Ok(())
}
pub async fn list_sources(state: &AppState) -> Result<Vec<SourceRecord>> {
let graph = state.graph.read().await;
let pattern = TriplePattern::any().with_predicate(Predicate::named(PRED_SOURCE_HASH));
let triples = graph
.find(pattern)
.map_err(|e| Error::Internal(format!("graph find error: {e}")))?;
Ok(triples
.iter()
.filter_map(|t| {
let path = t
.subject
.to_string()
.trim_start_matches('<')
.trim_end_matches('>')
.to_string();
t.object_string().map(|h| SourceRecord {
path,
content_hash: h.to_string(),
})
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn write(dir: &std::path::Path, name: &str, body: &str) {
std::fs::write(dir.join(name), body).unwrap();
}
#[test]
fn broad_source_and_doc_extensions_are_ingestable() {
for f in [
"App.swift",
"View.kt",
"Main.java",
"server.go",
"Component.tsx",
"util.jsx",
"lib.rb",
"index.php",
"query.sql",
"config.yaml",
"styles.scss",
"Widget.dart",
"notes.md",
"main.c",
"engine.cpp",
] {
assert!(
is_ingestable_file(std::path::Path::new(f)),
"{f} should be ingestable"
);
}
}
#[test]
fn wellknown_extensionless_files_are_ingestable() {
for f in [
"Dockerfile",
"Makefile",
"README",
"LICENSE",
"CMakeLists.txt",
] {
assert!(
is_ingestable_file(std::path::Path::new(f)),
"{f} should be ingestable"
);
}
}
#[test]
fn generated_lock_and_minified_noise_is_skipped() {
for f in [
"app.min.js",
"styles.min.css",
"bundle.js.map",
"package-lock.json",
"yarn.lock",
"Cargo.lock",
"Podfile.lock",
"pnpm-lock.yaml",
"types.generated.ts",
] {
assert!(
!is_ingestable_file(std::path::Path::new(f)),
"{f} should be skipped as noise"
);
}
}
#[test]
fn binaries_and_unknown_types_are_skipped() {
for f in ["photo.png", "archive.zip", "app.bin", "font.woff2", "noext"] {
assert!(
!is_ingestable_file(std::path::Path::new(f)),
"{f} should be skipped"
);
}
}
async fn enabled_state() -> AppState {
let state = AppState::with_db_path(":memory:", None).unwrap();
{
let mut graph = state.graph.write().await;
graph.enable_dag();
}
state
}
#[tokio::test]
async fn ingest_writes_triples_and_chunks() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"note.md",
"# Title\n\nWe use [[sled]] for storage. #durability\n",
);
let state = enabled_state().await;
let report = ingest_path(&state, dir.path().to_str().unwrap(), None)
.await
.unwrap();
assert_eq!(report.files_seen, 1);
assert_eq!(report.files_ingested, 1);
assert!(report.triples_written >= 3); assert!(report.chunks_written >= 1);
let mem = state.memory.read().await;
let hits = mem.recall_text("sled storage").unwrap();
assert!(!hits.is_empty());
}
#[tokio::test]
async fn progress_callback_reports_per_file_and_completes() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "a.md", "# A\n\nalpha\n");
write(dir.path(), "b.md", "# B\n\nbeta\n");
write(dir.path(), "c.md", "# C\n\ngamma\n");
let state = enabled_state().await;
let calls = std::sync::Mutex::new(Vec::<(usize, usize)>::new());
let cb = |done: usize, total: usize| calls.lock().unwrap().push((done, total));
let report =
ingest_path_with_progress(&state, dir.path().to_str().unwrap(), None, Some(&cb))
.await
.unwrap();
assert_eq!(report.files_seen, 3);
let calls = calls.into_inner().unwrap();
assert!(!calls.is_empty(), "callback must fire");
assert!(
calls.iter().all(|&(_, t)| t == 3),
"total must be the file count: {calls:?}"
);
assert_eq!(
*calls.last().unwrap(),
(3, 3),
"must finish at 100% (3/3): {calls:?}"
);
}
#[tokio::test]
async fn reingesting_unchanged_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "note.md", "# Title\n\nStable [[content]].\n");
let state = enabled_state().await;
let root = dir.path().to_str().unwrap();
ingest_path(&state, root, None).await.unwrap();
let actions_after_first = {
let g = state.graph.read().await;
g.dag_store().unwrap().action_count()
};
let report2 = ingest_path(&state, root, None).await.unwrap();
let actions_after_second = {
let g = state.graph.read().await;
g.dag_store().unwrap().action_count()
};
assert_eq!(report2.files_skipped, 1);
assert_eq!(report2.files_ingested, 0);
assert_eq!(
actions_after_first, actions_after_second,
"re-ingesting unchanged files must write zero new DAG actions"
);
}
#[tokio::test]
async fn changed_file_reingests() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "note.md", "# A\n\nFirst [[x]].\n");
let state = enabled_state().await;
let root = dir.path().to_str().unwrap();
ingest_path(&state, root, None).await.unwrap();
write(dir.path(), "note.md", "# A\n\nSecond [[y]] changed.\n");
let report = ingest_path(&state, root, None).await.unwrap();
assert_eq!(report.files_ingested, 1);
assert_eq!(report.files_skipped, 0);
}
#[tokio::test]
async fn changed_file_purges_stale_chunks() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "note.md", "# A\n\nWe use sled for storage.\n");
let state = enabled_state().await;
let root = dir.path().to_str().unwrap();
ingest_path(&state, root, None).await.unwrap();
write(dir.path(), "note.md", "# A\n\nWe use rocksdb now.\n");
ingest_path(&state, root, None).await.unwrap();
let g = crate::service::ground::ground(&state, "We use sled for storage.", 5)
.await
.unwrap();
assert!(
!g.answer_context.iter().any(|c| c.text.contains("sled")),
"stale 'sled' chunk should be purged on re-ingest, got: {:?}",
g.answer_context
);
}
#[tokio::test]
async fn changed_file_purges_stale_triples() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "note.md", "# A\n\nSee [[sled]].\n");
let state = enabled_state().await;
let root = dir.path().to_str().unwrap();
ingest_path(&state, root, None).await.unwrap();
write(dir.path(), "note.md", "# A\n\nSee [[rocksdb]].\n");
ingest_path(&state, root, None).await.unwrap();
let graph = state.graph.read().await;
let links = graph
.find(
TriplePattern::any()
.with_subject(NodeId::named("note.md"))
.with_predicate(Predicate::named("links_to")),
)
.unwrap();
assert_eq!(
links.len(),
1,
"stale links_to should be purged, leaving only the new link, got: {links:?}"
);
}
#[tokio::test]
async fn inbox_staging_area_is_excluded_from_ingest() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"kept.md",
"# Kept\n\nWe use [[sled]] for storage.\n",
);
std::fs::create_dir_all(dir.path().join("_inbox")).unwrap();
write(
&dir.path().join("_inbox"),
"proposal.md",
"# Proposal\n\nUnreviewed claim about [[quantum]].\n",
);
let state = enabled_state().await;
let root = dir.path().to_str().unwrap();
let report = ingest_path(&state, root, None).await.unwrap();
assert_eq!(report.files_seen, 1, "only the approved note is walked");
assert_eq!(report.files_ingested, 1);
let g = crate::service::ground::ground(&state, "Unreviewed claim about quantum", 5)
.await
.unwrap();
assert!(
!g.answer_context.iter().any(|c| c.source.contains("_inbox")),
"no _inbox source may appear in retrieval, got: {:?}",
g.answer_context
);
}
#[cfg(feature = "dag")]
#[tokio::test]
async fn approval_is_listed_after_ingest_with_named_author() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"note.md",
"# A\n\nWe use [[sled]]. #durability\n",
);
let mut state = enabled_state().await;
state.dag_author = Some(NodeId::named("Alice"));
let root = dir.path().to_str().unwrap();
ingest_path(&state, root, Some("initial".into()))
.await
.unwrap();
crate::service::review::record_approval(&state, "note.md", "approved body", "mcp")
.await
.unwrap();
let approvals = crate::service::review::list_approvals(&state, 50).await;
assert_eq!(
approvals.len(),
1,
"the approval must be listed after an ingest under the same named author; got {approvals:?}"
);
assert_eq!(approvals[0].note_path, "note.md");
}
#[tokio::test]
async fn ingest_missing_path_is_graceful() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("vaults").join("deadbeef-not-created");
assert!(!missing.exists());
let state = enabled_state().await;
let report = ingest_path(&state, missing.to_str().unwrap(), None)
.await
.expect("ingesting a missing path must not error");
assert_eq!(report.files_seen, 0);
assert_eq!(report.files_ingested, 0);
assert_eq!(report.triples_written, 0);
}
}