use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
pub(crate) mod atomic_write;
use atomic_write::atomic_write;
use crate::chunker::{self, ChunkerConfig};
use crate::sanitize;
use crate::segmentation::semantic_segments;
use crate::timeline::{RepoIdentity, SemanticSegment, TimelineEntry};
pub use aicx_parser::{classify_kind, timeline::Kind};
pub fn session_basename(date: &str, agent: &str, session_id: &str, chunk: u32) -> String {
let date_compact = compact_date(date);
let sid = truncate_session_id(session_id);
format!("{}_{}_{}_{:03}.md", date_compact, agent, sid, chunk)
}
pub(crate) fn compact_date(date: &str) -> String {
let digits: String = date.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() >= 8 {
format!("{}_{}", &digits[..4], &digits[4..8])
} else {
date.replace('-', "_")
}
}
fn truncate_session_id(session_id: &str) -> String {
let cleaned: String = session_id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect();
const LIMIT: usize = 20;
if cleaned.len() <= LIMIT {
return cleaned;
}
format!("{}-h{}", &cleaned[..LIMIT], siphash13_hex6(session_id))
}
fn siphash13_hex6(input: &str) -> String {
use siphasher::sip::SipHasher13;
use std::hash::{Hash, Hasher};
let mut hasher = SipHasher13::new();
input.hash(&mut hasher);
format!("{:06x}", (hasher.finish() & 0x00FF_FFFF) as u32)
}
fn chunk_sequence_from_id(id: &str) -> Option<u32> {
id.rsplit('_').next().and_then(parse_chunk_component)
}
pub(crate) mod dedupe;
pub(crate) mod ignore;
pub(crate) mod paths;
pub(crate) mod sidecar;
pub use dedupe::content_sha256_exists_in_dir;
use dedupe::{DirShaCache, content_sha256, sha256_of_file};
pub use ignore::{
AICX_IGNORE_FILENAME, StoreIgnoreMatcher, filter_ignored_paths_at, load_ignore_matcher_at,
};
use paths::aicx_context_corpus_dir_for;
pub(crate) use paths::canonical_project_slug;
use paths::validated_store_project_dir;
pub use paths::{
CANONICAL_STORE_DIRNAME, CONTEXT_CORPUS_DIRNAME, CONTEXT_CORPUS_SCHEMA_VERSION,
LEGACY_SALVAGE_DIRNAME, LOCT_CONTEXT_PACK_FAMILY, NON_REPOSITORY_CONTEXTS,
aicx_context_corpus_dir, canonical_store_dir, chunks_dir, chunks_dir_for,
context_corpus_root_dir, get_context_json_path, get_context_path, legacy_store_base_dir,
non_repository_contexts_dir, project_dir, resolve_aicx_home, store_base_dir,
store_base_dir_for,
};
use sidecar::load_sidecar_from_path;
pub use sidecar::{is_context_corpus_sidecar, load_sidecar, sidecar_path_for_chunk};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StoreIndex {
pub projects: HashMap<String, ProjectIndex>,
pub last_updated: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProjectIndex {
pub agents: HashMap<String, AgentIndex>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentIndex {
pub dates: Vec<String>,
pub total_entries: usize,
pub last_updated: DateTime<Utc>,
}
pub fn load_index() -> StoreIndex {
let base = match store_base_dir() {
Ok(dir) => dir,
Err(_) => return StoreIndex::default(),
};
let lock_path = match crate::locks::index_lock_path() {
Ok(path) => path,
Err(err) => {
tracing::warn!("failed to resolve index lock path: {err}");
return StoreIndex::default();
}
};
let _lock = match crate::locks::acquire_shared(lock_path) {
Ok(lock) => lock,
Err(err) => {
tracing::warn!("failed to acquire shared index lock: {err}");
return StoreIndex::default();
}
};
match load_index_at(&base) {
Ok(idx) => idx,
Err(err) => {
tracing::warn!("failed to load store index (returning empty default): {err:#}");
StoreIndex::default()
}
}
}
fn load_index_at(base: &Path) -> Result<StoreIndex> {
let path = base.join("index.json");
if !path.exists() {
return Ok(StoreIndex::default());
}
match read_and_parse_index(&path) {
Ok(idx) => Ok(idx),
Err(primary_err) => {
let bak_path = path.with_extension("json.bak");
tracing::warn!(
path = %path.display(),
bak = %bak_path.display(),
"store index corrupt or unreadable ({primary_err:#}); attempting .bak recovery"
);
if bak_path.exists() {
match read_and_parse_index(&bak_path) {
Ok(idx) => {
tracing::warn!("recovered store index from {}", bak_path.display());
return Ok(idx);
}
Err(bak_err) => {
return Err(anyhow!(
"store index unreadable and .bak fallback also failed (primary: {primary_err:#}; bak: {bak_err:#})"
));
}
}
}
Err(primary_err.context(format!(
"store index unreadable and no .bak sibling at {}",
bak_path.display()
)))
}
}
}
fn read_and_parse_index(path: &Path) -> Result<StoreIndex> {
let contents = sanitize::read_to_string_validated(path)
.with_context(|| format!("read failed: {}", path.display()))?;
serde_json::from_str(&contents).with_context(|| format!("parse failed: {}", path.display()))
}
pub fn save_index(index: &StoreIndex) -> Result<()> {
let base = store_base_dir()?;
let lock = crate::locks::acquire_exclusive(crate::locks::index_lock_path()?)?;
let result = save_index_at(&base, index);
crate::locks::release(lock);
result
}
fn save_index_at(base: &Path, index: &StoreIndex) -> Result<()> {
let path = base.join("index.json");
let json = serde_json::to_string_pretty(index).context("Failed to serialize index")?;
let bak = path.with_extension("json.bak");
match fs::OpenOptions::new().read(true).open(&path) {
Ok(mut src) => {
let copy_result: Result<u64> = (|| {
let mut dst = sanitize::create_file_validated(&bak)?;
std::io::copy(&mut src, &mut dst)
.with_context(|| format!("copy {} -> {}", path.display(), bak.display()))
})();
if let Err(err) = copy_result {
tracing::warn!(
src = %path.display(),
dst = %bak.display(),
"failed to snapshot index to .bak before save: {err}"
);
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
tracing::warn!(
src = %path.display(),
dst = %bak.display(),
"failed to open index before .bak snapshot: {err}"
);
}
}
atomic_write(&path, json.as_bytes())
.with_context(|| format!("Failed to write index: {}", path.display()))?;
Ok(())
}
pub fn update_index(
index: &mut StoreIndex,
project: &str,
agent: &str,
date: &str,
entry_count: usize,
) {
let now = Utc::now();
index.last_updated = now;
let project_idx = index
.projects
.entry(canonical_project_slug(project))
.or_default();
let agent_idx = project_idx.agents.entry(agent.to_string()).or_default();
if !agent_idx.dates.contains(&date.to_string()) {
agent_idx.dates.push(date.to_string());
agent_idx.dates.sort();
}
agent_idx.total_entries += entry_count;
agent_idx.last_updated = now;
}
pub fn list_stored_projects(index: &StoreIndex) -> Vec<String> {
let mut projects: Vec<String> = index.projects.keys().cloned().collect();
projects.sort();
projects
}
#[derive(Debug, Clone)]
pub struct StoredContextFile {
pub path: PathBuf,
pub project: String,
pub repo: Option<RepoIdentity>,
pub date_compact: String,
pub date_iso: String,
pub kind: Kind,
pub agent: String,
pub session_id: String,
pub chunk: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReadContextChunk {
pub path: PathBuf,
pub relative_path: String,
pub project: String,
pub date: String,
pub kind: String,
pub agent: String,
pub session_id: String,
pub chunk: u32,
pub bytes: u64,
pub content: String,
pub truncated: bool,
}
#[derive(Debug, Clone, Default)]
pub struct StoreWriteSummary {
pub total_entries: usize,
pub written_paths: Vec<PathBuf>,
pub skipped_empty_body: usize,
pub deduped_chunks: usize,
pub project_summary: BTreeMap<String, BTreeMap<String, usize>>,
}
#[derive(Debug, Clone, Default)]
struct SessionWriteOutcome {
written_paths: Vec<PathBuf>,
written_date_counts: BTreeMap<String, usize>,
skipped_empty_body: usize,
deduped_chunks: usize,
}
struct SessionWriteSpec<'a> {
project: Option<&'a str>,
agent: &'a str,
date: &'a str,
session_id: &'a str,
kind: Option<Kind>,
}
pub fn write_context(
project: &str,
agent: &str,
date: &str,
time: &str,
entries: &[TimelineEntry],
) -> Result<Vec<PathBuf>> {
let project = canonical_project_slug(project);
let mut written = Vec::new();
let md_path = get_context_path(&project, agent, date, time)?;
let mut md_content = String::new();
md_content.push_str(&format!("# {} | {} | {}\n\n", project, agent, date));
for entry in entries {
let ts = entry.timestamp.format("%Y-%m-%d %H:%M:%S UTC");
md_content.push_str(&format!("### {} | {}\n", ts, entry.role));
for line in entry.message.lines() {
md_content.push_str(&format!("> {}\n", line));
}
md_content.push('\n');
}
let write_path = sanitize::validate_write_path(&md_path)?;
atomic_write(&write_path, md_content.as_bytes())?;
written.push(md_path);
let json_path = get_context_json_path(&project, agent, date, time)?;
let json_content = serde_json::to_string_pretty(entries)?;
let write_path = sanitize::validate_write_path(&json_path)?;
atomic_write(&write_path, json_content.as_bytes())?;
written.push(json_path);
Ok(written)
}
pub fn write_context_chunked(
project: &str,
agent: &str,
date: &str,
time: &str,
entries: &[TimelineEntry],
chunker_config: &ChunkerConfig,
) -> Result<Vec<PathBuf>> {
if entries.is_empty() {
return Ok(vec![]);
}
let project = canonical_project_slug(project);
let chunks = chunker::chunk_entries(entries, &project, agent, chunker_config);
let dir = validated_store_project_dir(&canonical_store_dir()?, &project)?.join(date);
fs::create_dir_all(&dir)?;
let mut written = Vec::new();
for chunk in &chunks {
let seq = chunk.id.rsplit('_').next().unwrap_or("001");
let filename = format!("{}_{}-{}.md", time, agent, seq);
let path = dir.join(&filename);
let write_path = sanitize::validate_write_path(&path)?;
atomic_write(&write_path, chunk.text.as_bytes())?;
written.push(path);
}
Ok(written)
}
pub fn write_context_session_first(
project: &str,
agent: &str,
date: &str,
session_id: &str,
entries: &[TimelineEntry],
chunker_config: &ChunkerConfig,
kind: Option<Kind>,
) -> Result<Vec<PathBuf>> {
let mut sha_cache = DirShaCache::default();
Ok(write_context_session_first_outcome_at(
&canonical_store_dir()?,
SessionWriteSpec {
project: Some(project),
agent,
date,
session_id,
kind,
},
entries,
chunker_config,
&mut sha_cache,
)?
.written_paths)
}
#[cfg(test)]
fn write_context_session_first_at(
root: &Path,
spec: SessionWriteSpec<'_>,
entries: &[TimelineEntry],
chunker_config: &ChunkerConfig,
) -> Result<Vec<PathBuf>> {
let mut sha_cache = DirShaCache::default();
Ok(
write_context_session_first_outcome_at(
root,
spec,
entries,
chunker_config,
&mut sha_cache,
)?
.written_paths,
)
}
fn write_context_session_first_outcome_at(
root: &Path,
spec: SessionWriteSpec<'_>,
entries: &[TimelineEntry],
chunker_config: &ChunkerConfig,
sha_cache: &mut DirShaCache,
) -> Result<SessionWriteOutcome> {
if entries.is_empty() {
return Ok(SessionWriteOutcome::default());
}
let kind = spec.kind.unwrap_or_else(|| classify_kind(entries));
let project_label = spec
.project
.map(canonical_project_slug)
.unwrap_or_else(|| NON_REPOSITORY_CONTEXTS.to_string());
let chunks = chunker::chunk_entries(entries, &project_label, spec.agent, chunker_config);
let mut outcome = SessionWriteOutcome::default();
for (idx, chunk) in chunks.iter().enumerate() {
if chunk_body_is_empty(&chunk.text) {
outcome.skipped_empty_body += 1;
continue;
}
let chunk_date = if chunk.date.trim().is_empty() {
spec.date
} else {
chunk.date.as_str()
};
let date_dir = compact_date(chunk_date);
let chunk_num = chunk_sequence_from_id(&chunk.id).unwrap_or((idx as u32) + 1);
let mut dir = root.join(&date_dir).join(kind.dir_name()).join(spec.agent);
if spec.project.is_some() {
dir = validated_store_project_dir(root, &project_label)?
.join(&date_dir)
.join(kind.dir_name())
.join(spec.agent);
}
fs::create_dir_all(&dir)?;
let filename = session_basename(chunk_date, spec.agent, spec.session_id, chunk_num);
let path = dir.join(&filename);
let content_sha256 = content_sha256(&chunk.text);
if sha_cache.contains(&dir, &content_sha256)? {
outcome.deduped_chunks += 1;
continue;
}
let target_path = if path.exists() {
let existing_sidecar = path.with_extension("meta.json");
if !existing_sidecar.exists() {
let orphan_sha = sha256_of_file(&path)?;
if orphan_sha == content_sha256 {
let mut sidecar = chunker::ChunkMetadataSidecar::from(chunk);
sidecar.content_sha256 = Some(content_sha256.clone());
let sidecar_bytes = serde_json::to_vec_pretty(&sidecar)?;
let sidecar_write = sanitize::validate_write_path(&existing_sidecar)?;
atomic_write(&sidecar_write, &sidecar_bytes)?;
sha_cache.insert(&dir, content_sha256);
tracing::info!(
target: "aicx::store",
orphan = %path.display(),
"reclaimed orphan chunk by writing missing sidecar"
);
outcome.deduped_chunks += 1;
continue;
}
let quarantine_dir = dir.join("quarantine");
fs::create_dir_all(&quarantine_dir)?;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("chunk");
let quar_path = quarantine_dir.join(format!("{}-orphan-{}.md", stem, stamp));
fs::rename(&path, &quar_path).with_context(|| {
format!(
"Failed to quarantine orphan {} -> {}",
path.display(),
quar_path.display()
)
})?;
atomic_write::parent_fsync(&path);
atomic_write::parent_fsync(&quar_path);
tracing::warn!(
target: "aicx::store",
orphan = %path.display(),
quarantine = %quar_path.display(),
orphan_sha = %orphan_sha,
new_sha = %content_sha256,
"quarantined orphan .md (sidecar missing, body mismatch) to free canonical slot"
);
path
} else {
let existing_sha =
load_sidecar_from_path(&existing_sidecar).and_then(|s| s.content_sha256);
if existing_sha.as_deref() == Some(content_sha256.as_str()) {
outcome.deduped_chunks += 1;
continue;
}
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("chunk");
let disambig =
dir.join(format!("{}-c{}.md", stem, siphash13_hex6(&content_sha256)));
tracing::warn!(
target: "aicx::store",
existing = %path.display(),
disambiguated = %disambig.display(),
existing_sha = ?existing_sha,
"session-first chunk basename collision; writing under disambiguated path"
);
disambig
}
} else {
path
};
let write_path = sanitize::validate_write_path(&target_path)?;
let sidecar_path = target_path.with_extension("meta.json");
let sidecar_write_path = sanitize::validate_write_path(&sidecar_path)?;
let mut sidecar = chunker::ChunkMetadataSidecar::from(chunk);
sidecar.content_sha256 = Some(content_sha256.clone());
let sidecar_bytes = serde_json::to_vec_pretty(&sidecar)?;
let chunk_tmp = atomic_write::stage_tempfile(&write_path, chunk.text.as_bytes())?;
let sidecar_tmp = match atomic_write::stage_tempfile(&sidecar_write_path, &sidecar_bytes) {
Ok(tmp) => tmp,
Err(err) => {
atomic_write::discard_tempfile(&chunk_tmp);
return Err(err.into());
}
};
if let Err(err) = atomic_write::commit_tempfile(&chunk_tmp, &write_path) {
atomic_write::discard_tempfile(&chunk_tmp);
atomic_write::discard_tempfile(&sidecar_tmp);
return Err(err.into());
}
if let Err(err) = atomic_write::commit_tempfile(&sidecar_tmp, &sidecar_write_path) {
atomic_write::discard_tempfile(&sidecar_tmp);
return Err(err.into());
}
atomic_write::parent_fsync(&write_path);
if write_path.parent() != sidecar_write_path.parent() {
atomic_write::parent_fsync(&sidecar_write_path);
}
sha_cache.insert(&dir, content_sha256);
*outcome
.written_date_counts
.entry(date_dir.clone())
.or_default() += 1;
outcome.written_paths.push(target_path);
}
Ok(outcome)
}
fn chunk_body_after_header(content: &str) -> &str {
let Some(rest) = content.strip_prefix("[project:") else {
return content;
};
let Some((_, body)) = rest.split_once('\n') else {
return "";
};
body.trim_start_matches(['\r', '\n'])
}
fn chunk_body_is_empty(content: &str) -> bool {
!chunk_body_after_header(content)
.lines()
.any(chunk_line_has_signal)
}
fn chunk_line_has_signal(line: &str) -> bool {
let line = line.trim();
if line.is_empty() {
return false;
}
if let Some((_, rest)) = line.split_once("] ")
&& let Some((_, message)) = rest.split_once(':')
{
return !message.trim().is_empty();
}
true
}
pub fn store_semantic_segments(
entries: &[TimelineEntry],
chunker_config: &ChunkerConfig,
) -> Result<StoreWriteSummary> {
store_semantic_segments_with_progress(entries, chunker_config, |_, _| {})
}
pub fn store_semantic_segments_with_progress<F>(
entries: &[TimelineEntry],
chunker_config: &ChunkerConfig,
progress: F,
) -> Result<StoreWriteSummary>
where
F: FnMut(usize, usize),
{
store_semantic_segments_at(&store_base_dir()?, entries, chunker_config, progress)
}
pub fn store_semantic_segments_at<F>(
base: &Path,
entries: &[TimelineEntry],
chunker_config: &ChunkerConfig,
progress: F,
) -> Result<StoreWriteSummary>
where
F: FnMut(usize, usize),
{
if entries.is_empty() {
return Ok(StoreWriteSummary::default());
}
let segments = semantic_segments(entries);
store_segments_at(base, &segments, chunker_config, progress)
}
pub fn store_segments_at<F>(
base: &Path,
segments: &[SemanticSegment],
chunker_config: &ChunkerConfig,
mut progress: F,
) -> Result<StoreWriteSummary>
where
F: FnMut(usize, usize),
{
let mut summary = StoreWriteSummary::default();
if segments.is_empty() {
return Ok(summary);
}
let _lock = crate::locks::acquire_exclusive(base.join("locks").join("index.lock"))?;
let total_segments = segments.len();
let mut guard = IndexSaveGuard {
base,
index: load_index_at(base)?,
persisted: false,
};
let mut sha_cache = DirShaCache::default();
for (segment_idx, segment) in segments.iter().enumerate() {
let date = segment
.entries
.first()
.map(|entry| entry.timestamp.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| Utc::now().format("%Y-%m-%d").to_string());
let project = canonical_project_slug(&segment.project_label());
let outcome =
write_semantic_segment_at(base, segment, &date, chunker_config, &mut sha_cache)?;
summary.skipped_empty_body += outcome.skipped_empty_body;
summary.deduped_chunks += outcome.deduped_chunks;
let chunks_written = outcome.written_paths.len();
let chunks_total = chunks_written + outcome.deduped_chunks + outcome.skipped_empty_body;
let entries_committed_to_disk = if chunks_total == 0 || chunks_written == 0 {
0
} else {
(segment.entries.len() * chunks_written + chunks_total / 2) / chunks_total
};
*summary
.project_summary
.entry(project.clone())
.or_default()
.entry(segment.agent.clone())
.or_insert(0) += segment.entries.len();
summary.total_entries += segment.entries.len();
if entries_committed_to_disk > 0 {
if outcome.written_date_counts.is_empty() {
update_index(
&mut guard.index,
&project,
&segment.agent,
&compact_date(&date),
entries_committed_to_disk,
);
} else {
let total_written: usize = outcome.written_date_counts.values().sum();
let mut remaining_entries = entries_committed_to_disk;
let mut remaining_dates = outcome.written_date_counts.len();
for (date, chunks_for_date) in &outcome.written_date_counts {
let entry_count = if remaining_dates == 1 {
remaining_entries
} else {
let proportional =
entries_committed_to_disk * chunks_for_date / total_written;
let count = proportional.max(1).min(remaining_entries);
remaining_entries = remaining_entries.saturating_sub(count);
remaining_dates -= 1;
count
};
update_index(
&mut guard.index,
&project,
&segment.agent,
date,
entry_count,
);
}
}
}
summary.written_paths.extend(outcome.written_paths);
progress(segment_idx + 1, total_segments);
}
save_index_at(base, &guard.index)?;
guard.persisted = true;
Ok(summary)
}
struct IndexSaveGuard<'a> {
base: &'a Path,
index: StoreIndex,
persisted: bool,
}
impl Drop for IndexSaveGuard<'_> {
fn drop(&mut self) {
if self.persisted {
return;
}
match save_index_at(self.base, &self.index) {
Ok(()) => {
tracing::warn!(
target: "aicx::store",
base = %self.base.display(),
"store_segments_at returned early; index.json persisted opportunistically via IndexSaveGuard::drop"
);
}
Err(err) => {
tracing::error!(
target: "aicx::store",
base = %self.base.display(),
"IndexSaveGuard::drop failed to persist index.json: {err:#}"
);
eprintln!(
"aicx: IndexSaveGuard::drop failed to persist index.json at {}: {err:#}",
self.base.display()
);
}
}
}
}
fn write_semantic_segment_at(
base: &Path,
segment: &SemanticSegment,
date: &str,
chunker_config: &ChunkerConfig,
sha_cache: &mut DirShaCache,
) -> Result<SessionWriteOutcome> {
let project = if segment.has_assertable_identity() {
segment.repo.as_ref().map(RepoIdentity::slug)
} else {
None
};
let root = if project.is_some() {
base.join(CANONICAL_STORE_DIRNAME)
} else {
base.join(NON_REPOSITORY_CONTEXTS)
};
write_context_session_first_outcome_at(
&root,
SessionWriteSpec {
project: project.as_deref(),
agent: &segment.agent,
date,
session_id: &segment.session_id,
kind: Some(segment.kind),
},
&segment.entries,
chunker_config,
sha_cache,
)
}
pub fn scan_context_files() -> Result<Vec<StoredContextFile>> {
let base = store_base_dir()?;
scan_context_files_at(&base)
}
pub fn scan_context_files_raw() -> Result<Vec<StoredContextFile>> {
let base = store_base_dir()?;
scan_context_files_raw_at(&base)
}
pub fn scan_context_files_at(base: &Path) -> Result<Vec<StoredContextFile>> {
let base = sanitize::validate_dir_path(base)?;
let ignore = load_ignore_matcher_at(&base)?;
scan_context_files_with_ignore(&base, &ignore)
}
pub fn scan_context_files_project_at(
base: &Path,
project_filter: Option<&str>,
) -> Result<Vec<StoredContextFile>> {
let base = sanitize::validate_dir_path(base)?;
let Some(filter) = project_filter
.map(str::trim)
.filter(|filter| !filter.is_empty())
else {
return scan_context_files_at(&base);
};
let filter = filter.to_lowercase();
let ignore = load_ignore_matcher_at(&base)?;
let mut files = Vec::new();
let canonical_root = base.join(CANONICAL_STORE_DIRNAME);
if canonical_root.is_dir() {
scan_repo_store_filtered(&canonical_root, &ignore, &filter, &mut files)?;
}
let non_repo_root = base.join(NON_REPOSITORY_CONTEXTS);
if non_repo_root.is_dir() && NON_REPOSITORY_CONTEXTS.contains(&filter) {
scan_non_repository_store(&non_repo_root, &ignore, &mut files)?;
}
sort_context_files(&mut files);
Ok(files)
}
pub fn scan_context_files_raw_at(base: &Path) -> Result<Vec<StoredContextFile>> {
let base = sanitize::validate_dir_path(base)?;
let ignore = StoreIgnoreMatcher::empty_at(&base);
scan_context_files_with_ignore(&base, &ignore)
}
fn scan_context_files_with_ignore(
base: &Path,
ignore: &StoreIgnoreMatcher,
) -> Result<Vec<StoredContextFile>> {
let mut files = Vec::new();
let canonical_root = base.join(CANONICAL_STORE_DIRNAME);
if canonical_root.is_dir() {
scan_repo_store(&canonical_root, ignore, &mut files)?;
}
let non_repo_root = base.join(NON_REPOSITORY_CONTEXTS);
if non_repo_root.is_dir() {
scan_non_repository_store(&non_repo_root, ignore, &mut files)?;
}
sort_context_files(&mut files);
Ok(files)
}
fn sort_context_files(files: &mut [StoredContextFile]) {
files.sort_by(|left, right| {
left.date_compact
.cmp(&right.date_compact)
.then_with(|| left.project.cmp(&right.project))
.then_with(|| left.agent.cmp(&right.agent))
.then_with(|| left.session_id.cmp(&right.session_id))
.then_with(|| left.chunk.cmp(&right.chunk))
});
}
pub fn context_files_since(
cutoff: SystemTime,
project_filter: Option<&str>,
) -> Result<Vec<StoredContextFile>> {
context_files_since_at(&store_base_dir()?, cutoff, project_filter)
}
fn read_store_dir(path: &Path) -> Result<fs::ReadDir> {
let validated = sanitize::validate_dir_path(path)?;
fs::read_dir(&validated)
.with_context(|| format!("Failed to read store dir {}", validated.display()))
}
pub fn read_context_chunk(reference: &str, max_chars: Option<usize>) -> Result<ReadContextChunk> {
read_context_chunk_at(&store_base_dir()?, reference, max_chars)
}
pub fn read_context_chunk_at(
base: &Path,
reference: &str,
max_chars: Option<usize>,
) -> Result<ReadContextChunk> {
let base = sanitize::validate_dir_path(base)?;
let reference = reference.trim();
if reference.is_empty() {
return Err(anyhow!("chunk reference is required"));
}
let files = scan_context_files_at(&base)?;
let Some(file) = files
.into_iter()
.find(|file| stored_file_matches_reference(&base, file, reference))
else {
return Err(anyhow!("chunk not found: {reference}"));
};
let relative_path = file
.path
.strip_prefix(&base)
.unwrap_or(&file.path)
.to_string_lossy()
.to_string();
let path = sanitize::validate_read_path(&file.path)?;
let bytes = path.metadata().map(|meta| meta.len()).unwrap_or(0);
let content = sanitize::read_to_string_validated(&path)?;
let (content, truncated) = truncate_chars(content, max_chars);
Ok(ReadContextChunk {
path,
relative_path,
project: file.project,
date: file.date_iso,
kind: file.kind.dir_name().to_string(),
agent: file.agent,
session_id: file.session_id,
chunk: file.chunk,
bytes,
content,
truncated,
})
}
fn stored_file_matches_reference(base: &Path, file: &StoredContextFile, reference: &str) -> bool {
let path = file.path.to_string_lossy();
if path == reference {
return true;
}
let reference_path = Path::new(reference);
if reference_path.is_absolute() && reference_path == file.path {
return true;
}
if file
.path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == reference)
{
return true;
}
if file
.path
.strip_prefix(base)
.ok()
.is_some_and(|relative| relative.to_string_lossy() == reference)
{
return true;
}
let compact_ref = format!(
"{}|{}|{}|{}|{}|{:03}",
file.project,
file.date_iso,
file.kind.dir_name(),
file.agent,
file.session_id,
file.chunk
);
compact_ref == reference
}
fn truncate_chars(content: String, max_chars: Option<usize>) -> (String, bool) {
let Some(max_chars) = max_chars else {
return (content, false);
};
let mut iter = content.chars();
let truncated: String = iter.by_ref().take(max_chars).collect();
let was_truncated = iter.next().is_some();
(truncated, was_truncated)
}
fn context_files_since_at(
base: &Path,
cutoff: SystemTime,
project_filter: Option<&str>,
) -> Result<Vec<StoredContextFile>> {
let filter = project_filter
.map(str::trim)
.filter(|value| !value.is_empty());
let cutoff_date = DateTime::<Utc>::from(cutoff).format("%Y-%m-%d").to_string();
let mut files = scan_context_files_at(base)?;
files.retain(|file| {
let matches_project = match filter {
None => true,
Some(f) => {
let (org, repo) = file
.project
.split_once('/')
.unwrap_or(("", file.project.as_str()));
project_filter_matches(org, repo, f)
}
};
let matches_cutoff = file.date_iso >= cutoff_date;
matches_project && matches_cutoff
});
Ok(files)
}
#[derive(Debug, Clone)]
pub struct ContextCorpusFile {
pub raw_path: PathBuf,
pub sidecar_path: PathBuf,
pub sidecar: chunker::ChunkMetadataSidecar,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ContextCorpusIngestSummary {
pub target_dir: PathBuf,
pub raw_written: usize,
pub sidecars_written: usize,
pub deduped_chunks: usize,
pub index_path: PathBuf,
}
#[derive(Debug, Serialize, Deserialize)]
struct ContextCorpusIndexRow {
id: String,
path: String,
artifact_family: Option<String>,
schema_version: Option<String>,
truth_status_role: Option<String>,
keywords: Option<Vec<String>>,
band: Option<String>,
content_sha256: Option<String>,
}
pub fn ingest_loct_context_pack(pack_dir: &Path) -> Result<ContextCorpusIngestSummary> {
ingest_loct_context_pack_into(pack_dir, None)
}
fn ingest_loct_context_pack_into(
pack_dir: &Path,
home: Option<&Path>,
) -> Result<ContextCorpusIngestSummary> {
let pack_dir = sanitize::validate_dir_path(pack_dir)?;
let raw_dir = pack_dir.join("raw");
let sidecars_dir = pack_dir.join("sidecars");
let raw_dir = sanitize::validate_dir_path(&raw_dir)
.with_context(|| format!("loct context pack missing raw/: {}", raw_dir.display()))?;
let sidecars_dir = sanitize::validate_dir_path(&sidecars_dir).with_context(|| {
format!(
"loct context pack missing sidecars/: {}",
sidecars_dir.display()
)
})?;
let mut items = Vec::new();
for entry in read_store_dir(&raw_dir)?.filter_map(|entry| entry.ok()) {
let raw_path = entry.path();
if raw_path.extension().and_then(|ext| ext.to_str()) != Some("md") {
continue;
}
let Some(stem) = raw_path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
let sidecar_path = sidecars_dir.join(format!("{stem}.json"));
let mut sidecar = load_sidecar_from_path(&sidecar_path)
.with_context(|| format!("missing or invalid sidecar: {}", sidecar_path.display()))?;
sidecar.artifact_family = Some(LOCT_CONTEXT_PACK_FAMILY.to_string());
sidecar.schema_version = Some(CONTEXT_CORPUS_SCHEMA_VERSION.to_string());
if sidecar.truth_status.is_none() {
sidecar.truth_status = Some(chunker::TruthStatus {
role: chunker::TruthRole::Example,
runtime_authoritative: false,
stale_against_current_head: false,
current_head_when_ingested: None,
});
}
let raw = sanitize::read_to_string_validated(&raw_path)?;
let hash = content_sha256(&raw);
sidecar.content_sha256 = Some(hash);
items.push((raw_path, sidecar_path, sidecar));
}
if items.is_empty() {
anyhow::bail!("loct context pack contains no raw/*.md chunks");
}
let (org, repo) = context_corpus_repo_from_sidecar(&items[0].2)?;
let first_sidecar_path = items[0].1.clone();
if let Some((offender_path, offender_org, offender_repo)) =
items.iter().skip(1).find_map(|(_, sidecar_path, sidecar)| {
context_corpus_repo_from_sidecar(sidecar)
.ok()
.and_then(|(other_org, other_repo)| {
(other_org != org || other_repo != repo).then_some((
sidecar_path.clone(),
other_org,
other_repo,
))
})
})
{
anyhow::bail!(
"loct context pack {} mixes projects: first sidecar {} declares {}/{}, but sidecar {} declares {}/{}",
pack_dir.display(),
first_sidecar_path.display(),
org,
repo,
offender_path.display(),
offender_org,
offender_repo,
);
}
let date = items[0].2.date.clone();
let batch = pack_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("batch");
let target = match home {
Some(home) => aicx_context_corpus_dir_for(home, &org, &repo, &date, batch)?,
None => aicx_context_corpus_dir(&org, &repo, &date, batch)?,
};
let target_raw = target.join("raw");
let target_sidecars = target.join("sidecars");
let index_path = target.join("index.jsonl");
let mut seen_hashes = context_corpus_hashes_in_dir(&target_sidecars)?;
let mut index_rows = read_context_corpus_index_rows(&index_path)?;
let mut id_to_pos: HashMap<String, usize> = index_rows
.iter()
.enumerate()
.map(|(idx, row)| (row.id.clone(), idx))
.collect();
let mut summary = ContextCorpusIngestSummary {
target_dir: target.clone(),
index_path: index_path.clone(),
..ContextCorpusIngestSummary::default()
};
for (raw_path, _source_sidecar_path, sidecar) in items {
let hash = sidecar.content_sha256.clone().unwrap_or_default();
if !hash.is_empty() && seen_hashes.contains_key(&hash) {
summary.deduped_chunks += 1;
continue;
}
if !hash.is_empty() {
seen_hashes.insert(hash.clone(), sidecar.id.clone());
}
let file_name = raw_path
.file_name()
.ok_or_else(|| anyhow!("raw chunk missing filename: {}", raw_path.display()))?;
let raw_target = target_raw.join(file_name);
let sidecar_target = target_sidecars.join(format!(
"{}.json",
raw_target
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or(&sidecar.id)
));
let mut raw_src = sanitize::open_file_validated(&raw_path)?;
let mut raw_dst = sanitize::create_file_validated(&raw_target)?;
io::copy(&mut raw_src, &mut raw_dst)?;
raw_dst.flush()?;
raw_dst.sync_all()?;
let mut file = sanitize::create_file_validated(&sidecar_target)?;
file.write_all(serde_json::to_vec_pretty(&sidecar)?.as_slice())?;
summary.raw_written += 1;
summary.sidecars_written += 1;
let row = ContextCorpusIndexRow {
id: sidecar.id.clone(),
path: raw_target.display().to_string(),
artifact_family: sidecar.artifact_family.clone(),
schema_version: sidecar.schema_version.clone(),
truth_status_role: sidecar
.truth_status
.as_ref()
.map(|status| match status.role {
chunker::TruthRole::Live => "live".to_string(),
chunker::TruthRole::Example => "example".to_string(),
}),
keywords: sidecar.keywords.clone(),
band: sidecar.frame_kind.map(|kind| kind.as_str().to_string()),
content_sha256: sidecar.content_sha256.clone(),
};
match id_to_pos.get(&row.id).copied() {
Some(idx) => index_rows[idx] = row,
None => {
id_to_pos.insert(row.id.clone(), index_rows.len());
index_rows.push(row);
}
}
}
write_context_corpus_index(&index_path, &index_rows)?;
Ok(summary)
}
pub fn scan_context_corpus_files_at(base: &Path) -> Result<Vec<ContextCorpusFile>> {
let base = sanitize::validate_dir_path(base)?;
let root = base.join(CONTEXT_CORPUS_DIRNAME);
if !root.is_dir() {
return Ok(Vec::new());
}
let mut out = Vec::new();
scan_context_corpus_files_recursive(&root, &mut out)?;
out.sort_by(|left, right| left.raw_path.cmp(&right.raw_path));
Ok(out)
}
fn scan_context_corpus_files_recursive(dir: &Path, out: &mut Vec<ContextCorpusFile>) -> Result<()> {
for entry in read_store_dir(dir)?.filter_map(|entry| entry.ok()) {
let path = entry.path();
if path.is_dir() {
if path.file_name().and_then(|name| name.to_str()) == Some("raw") {
collect_context_corpus_raw_dir(&path, out)?;
} else {
scan_context_corpus_files_recursive(&path, out)?;
}
}
}
Ok(())
}
fn collect_context_corpus_raw_dir(raw_dir: &Path, out: &mut Vec<ContextCorpusFile>) -> Result<()> {
let Some(pack_dir) = raw_dir.parent() else {
return Ok(());
};
let sidecars_dir = pack_dir.join("sidecars");
if !sidecars_dir.is_dir() {
return Ok(());
}
for entry in read_store_dir(raw_dir)?.filter_map(|entry| entry.ok()) {
let raw_path = entry.path();
if raw_path.extension().and_then(|ext| ext.to_str()) != Some("md") {
continue;
}
let Some(stem) = raw_path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
let sidecar_path = sidecars_dir.join(format!("{stem}.json"));
let Some(sidecar) = load_sidecar_from_path(&sidecar_path) else {
continue;
};
out.push(ContextCorpusFile {
raw_path,
sidecar_path,
sidecar,
});
}
Ok(())
}
fn context_corpus_repo_from_sidecar(
sidecar: &chunker::ChunkMetadataSidecar,
) -> Result<(String, String)> {
let project = sidecar.project.trim();
if let Some((org, repo)) = project.split_once('/') {
return Ok((org.to_string(), repo.to_string()));
}
Ok(("unknown".to_string(), project.to_string()))
}
fn context_corpus_hashes_in_dir(sidecars_dir: &Path) -> Result<HashMap<String, String>> {
let mut hashes = HashMap::new();
if !sidecars_dir.exists() {
return Ok(hashes);
}
for entry in read_store_dir(sidecars_dir)?.filter_map(|entry| entry.ok()) {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let Some(sidecar) = load_sidecar_from_path(&path) else {
continue;
};
if let Some(hash) = sidecar.content_sha256 {
hashes.insert(hash, sidecar.id);
}
}
Ok(hashes)
}
fn write_context_corpus_index(path: &Path, rows: &[ContextCorpusIndexRow]) -> Result<()> {
let mut buf = Vec::with_capacity(rows.len() * 256);
for row in rows {
serde_json::to_writer(&mut buf, row)?;
buf.push(b'\n');
}
atomic_write(path, &buf)
.map_err(|err| anyhow!("write context corpus index {}: {}", path.display(), err))?;
Ok(())
}
fn read_context_corpus_index_rows(path: &Path) -> Result<Vec<ContextCorpusIndexRow>> {
if !path.exists() {
return Ok(Vec::new());
}
let content = sanitize::read_to_string_validated(path)?;
let mut rows = Vec::new();
for (line_no, raw_line) in content.lines().enumerate() {
let trimmed = raw_line.trim();
if trimmed.is_empty() {
continue;
}
let row: ContextCorpusIndexRow = serde_json::from_str(trimmed).with_context(|| {
format!(
"parse context corpus index row at {}:{}",
path.display(),
line_no + 1
)
})?;
rows.push(row);
}
Ok(rows)
}
pub fn chunks_by_run_id(run_id: &str, project: Option<&str>) -> Result<Vec<StoredContextFile>> {
let cutoff = SystemTime::now() - std::time::Duration::from_secs(7 * 24 * 3600);
chunks_by_run_id_at(&store_base_dir()?, run_id, project, cutoff)
}
fn chunks_by_run_id_at(
base: &Path,
run_id: &str,
project: Option<&str>,
cutoff: SystemTime,
) -> Result<Vec<StoredContextFile>> {
let project_filter = project.map(str::trim).filter(|value| !value.is_empty());
let cutoff_date = DateTime::<Utc>::from(cutoff).format("%Y-%m-%d").to_string();
let mut matched = Vec::new();
for file in scan_context_files_at(base)? {
let matches_project = match project_filter {
None => true,
Some(f) => {
let (org, repo) = file
.project
.split_once('/')
.unwrap_or(("", file.project.as_str()));
project_filter_matches(org, repo, f)
}
};
let matches_cutoff = file.date_iso >= cutoff_date;
if !matches_project || !matches_cutoff {
continue;
}
if load_sidecar(&file.path)
.and_then(|sidecar| sidecar.run_id)
.as_deref()
== Some(run_id)
{
matched.push(file);
}
}
Ok(matched)
}
fn scan_repo_store(
root: &Path,
ignore: &StoreIgnoreMatcher,
files: &mut Vec<StoredContextFile>,
) -> Result<()> {
for organization_entry in read_store_dir(root)?.filter_map(|entry| entry.ok()) {
let organization_path = organization_entry.path();
if !organization_path.is_dir() {
continue;
}
let organization = organization_entry.file_name().to_string_lossy().to_string();
for repository_entry in read_store_dir(&organization_path)?.filter_map(|entry| entry.ok()) {
let repository_path = repository_entry.path();
if !repository_path.is_dir() {
continue;
}
let repository = repository_entry.file_name().to_string_lossy().to_string();
let repo = RepoIdentity {
organization: organization.clone(),
repository: repository.clone(),
};
for date_entry in read_store_dir(&repository_path)?.filter_map(|entry| entry.ok()) {
let date_path = date_entry.path();
if !date_path.is_dir() {
continue;
}
let date_compact = date_entry.file_name().to_string_lossy().to_string();
for kind_entry in read_store_dir(&date_path)?.filter_map(|entry| entry.ok()) {
let kind_path = kind_entry.path();
if !kind_path.is_dir() {
continue;
}
let Some(kind) = Kind::parse(&kind_entry.file_name().to_string_lossy()) else {
continue;
};
for agent_entry in read_store_dir(&kind_path)?.filter_map(|entry| entry.ok()) {
let agent_path = agent_entry.path();
if !agent_path.is_dir() {
continue;
}
let agent = agent_entry.file_name().to_string_lossy().to_string();
let repo_slug = repo.slug();
let ctx = LeafScanContext {
repo: Some(repo.clone()),
project: &repo_slug,
date_compact: &date_compact,
kind,
agent: &agent,
};
collect_leaf_files(&agent_path, &ctx, ignore, files)?;
}
}
}
}
}
Ok(())
}
pub fn project_filter_matches(organization: &str, repository: &str, filter: &str) -> bool {
let filter = filter.trim();
if filter.is_empty() {
return false;
}
if let Some(repo_only) = filter.strip_prefix('/') {
if repo_only.is_empty() || repo_only.contains('/') {
return false;
}
return repository.eq_ignore_ascii_case(repo_only);
}
if let Some(org_only) = filter.strip_suffix('/') {
if org_only.is_empty() || org_only.contains('/') {
return false;
}
return organization.eq_ignore_ascii_case(org_only);
}
if filter.contains('/') {
let slug = format!("{organization}/{repository}");
return slug.eq_ignore_ascii_case(filter);
}
organization.eq_ignore_ascii_case(filter) || repository.eq_ignore_ascii_case(filter)
}
pub fn resolve_filters_to_slugs(filters: &[String]) -> Result<Vec<String>> {
let base = store_base_dir()?;
let canonical_root = base.join(CANONICAL_STORE_DIRNAME);
resolve_filters_to_slugs_at(&canonical_root, filters)
}
pub fn resolve_filters_to_slugs_or_error(filters: &[String]) -> Result<Vec<String>> {
let base = store_base_dir()?;
let canonical_root = base.join(CANONICAL_STORE_DIRNAME);
resolve_filters_to_slugs_at_or_error(&canonical_root, filters)
}
pub fn resolve_filters_to_slugs_at(
canonical_root: &Path,
filters: &[String],
) -> Result<Vec<String>> {
if filters.is_empty() {
return Ok(Vec::new());
}
if !canonical_root.is_dir() {
return Ok(Vec::new());
}
let mut slugs: Vec<String> = Vec::new();
for organization_entry in read_store_dir(canonical_root)?.filter_map(|entry| entry.ok()) {
let organization_path = organization_entry.path();
if !organization_path.is_dir() {
continue;
}
let organization = organization_entry.file_name().to_string_lossy().to_string();
for repository_entry in read_store_dir(&organization_path)?.filter_map(|entry| entry.ok()) {
let repository_path = repository_entry.path();
if !repository_path.is_dir() {
continue;
}
let repository = repository_entry.file_name().to_string_lossy().to_string();
if filters
.iter()
.any(|filter| project_filter_matches(&organization, &repository, filter))
{
let slug = format!("{organization}/{repository}");
if !slugs.iter().any(|existing| existing == &slug) {
slugs.push(slug);
}
}
}
}
slugs.sort();
Ok(slugs)
}
pub fn resolve_filters_to_slugs_at_or_error(
canonical_root: &Path,
filters: &[String],
) -> Result<Vec<String>> {
if filters.is_empty() {
return Ok(Vec::new());
}
let resolved = resolve_filters_to_slugs_at(canonical_root, filters)?;
if resolved.is_empty() {
anyhow::bail!(
"no project matches filter(s): {}\n \
accepted forms (case-insensitive): owner/repo (strict), \
owner/ (org wildcard), /repo (cross-org repo), name (cross-org)",
filters
.iter()
.map(|p| format!("{p:?}"))
.collect::<Vec<_>>()
.join(", ")
);
}
Ok(resolved)
}
pub fn resolve_filters_to_store_or_index_slugs_at_or_error(
store_root: &Path,
filters: &[String],
) -> Result<Vec<String>> {
if filters.is_empty() {
return Ok(Vec::new());
}
let canonical_root = store_root.join(CANONICAL_STORE_DIRNAME);
let mut slugs = std::collections::BTreeSet::new();
for slug in resolve_filters_to_slugs_at(&canonical_root, filters)? {
slugs.insert(slug);
}
let indexed_root = store_root.join("indexed");
for slug in resolve_filters_to_index_slugs_at(&indexed_root, filters)? {
slugs.insert(slug);
}
if slugs.is_empty() {
anyhow::bail!(
"no project matches filter(s): {}\n \
accepted forms (case-insensitive): owner/repo (strict), \
owner/ (org wildcard), /repo (cross-org repo), name (cross-org)",
filters
.iter()
.map(|p| format!("{p:?}"))
.collect::<Vec<_>>()
.join(", ")
);
}
Ok(slugs.into_iter().collect())
}
fn resolve_filters_to_index_slugs_at(
indexed_root: &Path,
filters: &[String],
) -> Result<Vec<String>> {
if !indexed_root.exists() {
return Ok(Vec::new());
}
let mut slugs = std::collections::BTreeSet::new();
let mut all_bucket: Option<PathBuf> = None;
for entry in sanitize::read_dir_validated(indexed_root)
.with_context(|| format!("read indexed root {}", indexed_root.display()))?
{
let entry =
entry.with_context(|| format!("read indexed entry in {}", indexed_root.display()))?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let bucket = entry.file_name().to_string_lossy().to_string();
let index_path = path.join("embeddings.ndjson");
if bucket == "_all" {
all_bucket = Some(index_path);
continue;
}
for slug in project_slugs_from_index_file(&index_path, filters, true)? {
slugs.insert(slug);
}
}
if let Some(index_path) = all_bucket {
for slug in project_slugs_from_index_file(&index_path, filters, false)? {
slugs.insert(slug);
}
}
Ok(slugs.into_iter().collect())
}
fn project_slugs_from_index_file(
index_path: &Path,
filters: &[String],
stop_after_first_match: bool,
) -> Result<Vec<String>> {
if !index_path.exists() {
return Ok(Vec::new());
}
let file = sanitize::open_file_validated(index_path)
.with_context(|| format!("open indexed project resolver: {}", index_path.display()))?;
let reader = io::BufReader::new(file);
let mut slugs = Vec::new();
for (idx, line) in reader.lines().enumerate() {
let line =
line.with_context(|| format!("read line {} in {}", idx + 1, index_path.display()))?;
if idx == 0 || line.trim().is_empty() {
continue;
}
let Ok(row) = serde_json::from_str::<ProjectOnlyIndexRow>(&line) else {
continue;
};
let Some(project) = row.project else {
continue;
};
if project_slug_matches_filters(project, filters)
&& !slugs.iter().any(|existing| existing == project)
{
slugs.push(project.to_string());
if stop_after_first_match {
break;
}
}
}
Ok(slugs)
}
#[derive(Deserialize)]
struct ProjectOnlyIndexRow<'a> {
project: Option<&'a str>,
}
fn project_slug_matches_filters(project: &str, filters: &[String]) -> bool {
let Some((organization, repository)) = project.split_once('/') else {
return false;
};
filters
.iter()
.any(|filter| project_filter_matches(organization, repository, filter))
}
pub fn detect_ambiguous_bare_filter(
filter: &str,
slugs: &[String],
) -> Option<(Vec<String>, Vec<String>)> {
let trimmed = filter.trim();
if trimmed.is_empty() || trimmed.contains('/') {
return None;
}
let mut as_org: Vec<String> = Vec::new();
let mut as_repo: Vec<String> = Vec::new();
for slug in slugs {
let Some((org, repo)) = slug.split_once('/') else {
continue;
};
if org.eq_ignore_ascii_case(trimmed) {
as_org.push(slug.clone());
}
if repo.eq_ignore_ascii_case(trimmed) {
as_repo.push(slug.clone());
}
}
if as_org.is_empty() || as_repo.is_empty() {
return None;
}
Some((as_org, as_repo))
}
fn scan_repo_store_filtered(
root: &Path,
ignore: &StoreIgnoreMatcher,
project_filter: &str,
files: &mut Vec<StoredContextFile>,
) -> Result<()> {
for organization_entry in read_store_dir(root)?.filter_map(|entry| entry.ok()) {
let organization_path = organization_entry.path();
if !organization_path.is_dir() {
continue;
}
let organization = organization_entry.file_name().to_string_lossy().to_string();
for repository_entry in read_store_dir(&organization_path)?.filter_map(|entry| entry.ok()) {
let repository_path = repository_entry.path();
if !repository_path.is_dir() {
continue;
}
let repository = repository_entry.file_name().to_string_lossy().to_string();
if !project_filter_matches(&organization, &repository, project_filter) {
continue;
}
let repo = RepoIdentity {
organization: organization.clone(),
repository: repository.clone(),
};
let repo_slug = repo.slug();
scan_single_repo_store(&repository_path, ignore, &repo, &repo_slug, files)?;
}
}
Ok(())
}
fn scan_single_repo_store(
repository_path: &Path,
ignore: &StoreIgnoreMatcher,
repo: &RepoIdentity,
repo_slug: &str,
files: &mut Vec<StoredContextFile>,
) -> Result<()> {
for date_entry in read_store_dir(repository_path)?.filter_map(|entry| entry.ok()) {
let date_path = date_entry.path();
if !date_path.is_dir() {
continue;
}
let date_compact = date_entry.file_name().to_string_lossy().to_string();
for kind_entry in read_store_dir(&date_path)?.filter_map(|entry| entry.ok()) {
let kind_path = kind_entry.path();
if !kind_path.is_dir() {
continue;
}
let Some(kind) = Kind::parse(&kind_entry.file_name().to_string_lossy()) else {
continue;
};
for agent_entry in read_store_dir(&kind_path)?.filter_map(|entry| entry.ok()) {
let agent_path = agent_entry.path();
if !agent_path.is_dir() {
continue;
}
let agent = agent_entry.file_name().to_string_lossy().to_string();
let ctx = LeafScanContext {
repo: Some(repo.clone()),
project: repo_slug,
date_compact: &date_compact,
kind,
agent: &agent,
};
collect_leaf_files(&agent_path, &ctx, ignore, files)?;
}
}
}
Ok(())
}
fn scan_non_repository_store(
root: &Path,
ignore: &StoreIgnoreMatcher,
files: &mut Vec<StoredContextFile>,
) -> Result<()> {
for date_entry in read_store_dir(root)?.filter_map(|entry| entry.ok()) {
let date_path = date_entry.path();
if !date_path.is_dir() {
continue;
}
let date_compact = date_entry.file_name().to_string_lossy().to_string();
for kind_entry in read_store_dir(&date_path)?.filter_map(|entry| entry.ok()) {
let kind_path = kind_entry.path();
if !kind_path.is_dir() {
continue;
}
let Some(kind) = Kind::parse(&kind_entry.file_name().to_string_lossy()) else {
continue;
};
for agent_entry in read_store_dir(&kind_path)?.filter_map(|entry| entry.ok()) {
let agent_path = agent_entry.path();
if !agent_path.is_dir() {
continue;
}
let agent = agent_entry.file_name().to_string_lossy().to_string();
let ctx = LeafScanContext {
repo: None,
project: NON_REPOSITORY_CONTEXTS,
date_compact: &date_compact,
kind,
agent: &agent,
};
collect_leaf_files(&agent_path, &ctx, ignore, files)?;
}
}
}
Ok(())
}
#[derive(Clone)]
struct LeafScanContext<'a> {
repo: Option<RepoIdentity>,
project: &'a str,
date_compact: &'a str,
kind: Kind,
agent: &'a str,
}
fn collect_leaf_files(
dir: &Path,
ctx: &LeafScanContext<'_>,
ignore: &StoreIgnoreMatcher,
files: &mut Vec<StoredContextFile>,
) -> Result<()> {
for file_entry in read_store_dir(dir)?.filter_map(|entry| entry.ok()) {
let path = file_entry.path();
let file_type = match file_entry.file_type() {
Ok(file_type) => file_type,
Err(_) => continue,
};
if file_type.is_symlink() || !file_type.is_file() {
continue;
}
if path
.extension()
.and_then(|ext| ext.to_str())
.is_none_or(|ext| ext != "md" && ext != "json")
{
continue;
}
if ignore.is_ignored(&path) {
continue;
}
let Some((session_id, chunk)) = parse_session_basename(
&file_entry.file_name().to_string_lossy(),
ctx.agent,
ctx.date_compact,
) else {
continue;
};
files.push(StoredContextFile {
path,
project: ctx.project.to_string(),
repo: ctx.repo.clone(),
date_compact: ctx.date_compact.to_string(),
date_iso: expand_compact_date(ctx.date_compact),
kind: ctx.kind,
agent: ctx.agent.to_string(),
session_id,
chunk,
});
}
Ok(())
}
fn parse_session_basename(name: &str, agent: &str, date_compact: &str) -> Option<(String, u32)> {
let ext = if name.ends_with(".md") {
".md"
} else if name.ends_with(".json") {
".json"
} else {
return None;
};
let stem = name.strip_suffix(ext)?;
let prefix = format!("{date_compact}_{agent}_");
let remainder = stem.strip_prefix(&prefix)?;
let (session_id, chunk_str) = remainder.rsplit_once('_')?;
if session_id.is_empty()
|| !session_id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
{
return None;
}
let chunk = parse_chunk_component(chunk_str)?;
Some((session_id.to_string(), chunk))
}
fn parse_chunk_component(value: &str) -> Option<u32> {
let digits = match value.split_once("-c") {
Some((digits, suffix))
if suffix.len() == 6 && suffix.chars().all(|ch| ch.is_ascii_hexdigit()) =>
{
digits
}
Some(_) => return None,
None => value,
};
if digits.len() < 3 || !digits.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
digits.parse().ok()
}
pub fn expand_compact_date(compact: &str) -> String {
let digits: String = compact.chars().filter(|ch| ch.is_ascii_digit()).collect();
if digits.len() >= 8 {
format!("{}-{}-{}", &digits[..4], &digits[4..6], &digits[6..8])
} else {
compact.to_string()
}
}
pub(crate) mod migration;
pub use migration::{
LegacyItemKind, MigrationAction, MigrationExecution, MigrationItem, MigrationManifest,
MigrationTotals, run_migration, run_migration_with_paths,
};
#[cfg(test)]
pub(crate) use migration::{SourceLocator, run_migration_at};
#[cfg(test)]
mod tests;