1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11use walkdir::WalkDir;
12
13use crate::{
14 db::{AimemDb, DbError},
15 embedder::{EmbedError, Embedder},
16 types::Drawer,
17};
18
19#[derive(Debug, Error)]
20pub enum MinerError {
21 #[error("db: {0}")]
22 Db(#[from] DbError),
23 #[error("embed: {0}")]
24 Embed(#[from] EmbedError),
25 #[error("io: {0}")]
26 Io(#[from] std::io::Error),
27 #[error("yaml: {0}")]
28 Yaml(#[from] serde_yaml::Error),
29 #[error("config not found at {0}")]
30 ConfigNotFound(String),
31}
32
33const CHUNK_SIZE: usize = 800; const CHUNK_OVERLAP: usize = 100; pub const MIN_CHUNK: usize = 50; static READABLE_EXT: &[&str] = &[
40 "txt",
41 "md",
42 "py",
43 "js",
44 "ts",
45 "jsx",
46 "tsx",
47 "json",
48 "yaml",
49 "yml",
50 "html",
51 "css",
52 "java",
53 "go",
54 "rs",
55 "rb",
56 "sh",
57 "csv",
58 "sql",
59 "toml",
60 "c",
61 "cpp",
62 "h",
63 "hpp",
64 "swift",
65 "kt",
66 "dart",
67 "lua",
68 "vim",
69 "conf",
70 "ini",
71 "env",
72 "Makefile",
73 "Dockerfile",
74];
75
76static SKIP_DIRS: &[&str] = &[
77 ".git",
78 "node_modules",
79 "__pycache__",
80 ".venv",
81 "venv",
82 "env",
83 "dist",
84 "build",
85 ".next",
86 "coverage",
87 ".aimem",
88 "target",
89 ".cargo",
90 ".idea",
91 ".vscode",
92];
93
94static SKIP_FILES: &[&str] = &[
95 "aimem.yaml",
96 "aimem.yml",
97 ".gitignore",
98 "package-lock.json",
99 "Cargo.lock",
100];
101
102#[derive(Debug, Clone, Deserialize, Serialize)]
105pub struct RoomDef {
106 pub name: String,
107 #[serde(default)]
108 pub description: String,
109 #[serde(default)]
110 pub keywords: Vec<String>,
111}
112
113#[derive(Debug, Clone, Deserialize, Serialize)]
114pub struct ProjectConfig {
115 pub wing: String,
116 #[serde(default = "default_rooms")]
117 pub rooms: Vec<RoomDef>,
118}
119
120fn default_rooms() -> Vec<RoomDef> {
121 vec![RoomDef {
122 name: "general".to_string(),
123 description: "All project files".to_string(),
124 keywords: vec![],
125 }]
126}
127
128impl ProjectConfig {
129 pub fn load(project_dir: impl AsRef<Path>) -> Result<Self, MinerError> {
130 let dir = project_dir.as_ref();
131 let yaml = dir.join("aimem.yaml");
132 let path = if yaml.exists() {
133 yaml
134 } else {
135 return Err(MinerError::ConfigNotFound(dir.display().to_string()));
136 };
137 let raw = std::fs::read_to_string(&path)?;
138 Ok(serde_yaml::from_str(&raw)?)
139 }
140}
141
142#[derive(Debug, Default)]
145pub struct MineStats {
146 pub files_scanned: usize,
147 pub files_skipped: usize, pub drawers_added: usize,
149 pub rooms: HashMap<String, usize>,
150}
151
152#[derive(Debug, Clone)]
156pub struct Miner {
157 db: AimemDb,
158 embedder: Option<Embedder>,
159}
160
161impl Miner {
162 pub fn new(db: AimemDb, embedder: Option<Embedder>) -> Self {
165 Self { db, embedder }
166 }
167
168 pub async fn mine(
177 &self,
178 project_dir: impl AsRef<Path>,
179 wing_override: Option<&str>,
180 agent: &str,
181 limit: usize,
182 dry_run: bool,
183 ) -> Result<MineStats, MinerError> {
184 let project_dir = project_dir.as_ref().canonicalize()?;
185 let cfg = ProjectConfig::load(&project_dir)?;
186 let wing = wing_override.unwrap_or(&cfg.wing).to_string();
187
188 let files = scan_files(&project_dir);
189 let files: Vec<_> = if limit > 0 {
190 files.into_iter().take(limit).collect()
191 } else {
192 files
193 };
194
195 let mut stats = MineStats::default();
196 stats.files_scanned = files.len();
197
198 for filepath in &files {
199 let source = filepath.to_string_lossy().to_string();
200
201 if !dry_run && self.db.source_already_mined(&source).await? {
203 stats.files_skipped += 1;
204 continue;
205 }
206
207 let content = match std::fs::read_to_string(filepath) {
208 Ok(c) => c,
209 Err(_) => continue,
210 };
211 let content = content.trim().to_string();
212 if content.len() < MIN_CHUNK {
213 continue;
214 }
215
216 let room = detect_room(filepath, &content, &cfg.rooms, &project_dir);
217 let chunks = chunk_text(&content);
218
219 if dry_run {
220 tracing::info!(
221 "[DRY RUN] {} → room:{} ({} chunks)",
222 filepath.file_name().unwrap_or_default().to_string_lossy(),
223 room,
224 chunks.len()
225 );
226 stats.drawers_added += chunks.len();
227 *stats.rooms.entry(room.clone()).or_default() += 1;
228 continue;
229 }
230
231 let embeddings: Vec<Option<Vec<f32>>> = if let Some(ref emb) = self.embedder {
233 let texts: Vec<&str> = chunks.iter().map(|c| c.as_str()).collect();
234 match emb.embed(&texts) {
235 Ok(vecs) => vecs.into_iter().map(Some).collect(),
236 Err(e) => {
237 tracing::warn!("embedding failed for {source}: {e}");
238 vec![None; chunks.len()]
239 }
240 }
241 } else {
242 vec![None; chunks.len()]
243 };
244
245 let now = chrono::Utc::now().to_rfc3339();
246 let mut file_drawers = 0usize;
247 for (i, (chunk, embedding)) in chunks.iter().zip(embeddings.iter()).enumerate() {
248 let id = drawer_id(&wing, &room, &source, i);
249 let drawer = Drawer {
250 id,
251 wing: wing.clone(),
252 room: room.clone(),
253 content: chunk.clone(),
254 source_file: Some(source.clone()),
255 chunk_index: i as i64,
256 added_by: agent.to_string(),
257 filed_at: now.clone(),
258 };
259 if self.db.insert_drawer(&drawer, embedding.as_deref()).await? {
260 file_drawers += 1;
261 }
262 }
263
264 if file_drawers > 0 {
265 stats.drawers_added += file_drawers;
266 *stats.rooms.entry(room).or_default() += 1;
267 tracing::debug!(
268 "✓ {} +{}",
269 filepath.file_name().unwrap_or_default().to_string_lossy(),
270 file_drawers
271 );
272 }
273 }
274
275 Ok(stats)
276 }
277}
278
279pub fn scan_files(project_dir: &Path) -> Vec<PathBuf> {
283 WalkDir::new(project_dir)
284 .follow_links(false)
285 .into_iter()
286 .filter_entry(|e| {
287 let name = e.file_name().to_string_lossy();
288 !SKIP_DIRS.contains(&name.as_ref())
289 })
290 .filter_map(|e| e.ok())
291 .filter(|e| e.file_type().is_file())
292 .filter(|e| {
293 let name = e.file_name().to_string_lossy();
294 if SKIP_FILES.contains(&name.as_ref()) {
295 return false;
296 }
297 let ext = e
298 .path()
299 .extension()
300 .map(|x| x.to_string_lossy().to_lowercase())
301 .unwrap_or_default();
302 READABLE_EXT.contains(&ext.as_str()) || ext.is_empty()
304 })
305 .map(|e| e.into_path())
306 .collect()
307}
308
309pub fn detect_room(
317 filepath: &Path,
318 content: &str,
319 rooms: &[RoomDef],
320 project_root: &Path,
321) -> String {
322 let relative = filepath
323 .strip_prefix(project_root)
324 .unwrap_or(filepath)
325 .to_string_lossy()
326 .to_lowercase();
327 let stem = filepath
328 .file_stem()
329 .unwrap_or_default()
330 .to_string_lossy()
331 .to_lowercase();
332 let content_lower = &content[..content.len().min(2000)].to_lowercase();
333 let path_parts: Vec<&str> = relative.split('/').collect();
334
335 for part in path_parts.iter().take(path_parts.len().saturating_sub(1)) {
337 for room in rooms {
338 let rn = room.name.to_lowercase();
339 if rn == *part || part.contains(rn.as_str()) {
340 return room.name.clone();
341 }
342 }
343 }
344
345 for room in rooms {
347 let rn = room.name.to_lowercase();
348 if stem.contains(rn.as_str()) || rn.contains(stem.as_str()) {
349 return room.name.clone();
350 }
351 }
352
353 let mut scores: Vec<(usize, &str)> = rooms
355 .iter()
356 .map(|r| {
357 let mut score = 0usize;
358 let all_kw: Vec<String> = r
359 .keywords
360 .iter()
361 .chain(std::iter::once(&r.name))
362 .cloned()
363 .collect();
364 for kw in &all_kw {
365 let kw_lower = kw.to_lowercase();
366 let mut pos = 0;
367 while let Some(idx) = content_lower[pos..].find(kw_lower.as_str()) {
368 score += 1;
369 pos += idx + kw_lower.len();
370 }
371 }
372 (score, r.name.as_str())
373 })
374 .collect();
375 scores.sort_by(|a, b| b.0.cmp(&a.0));
376 if let Some((score, name)) = scores.first() {
377 if *score > 0 {
378 return name.to_string();
379 }
380 }
381
382 "general".to_string()
383}
384
385pub fn chunk_text(content: &str) -> Vec<String> {
387 let content = content.trim();
388 if content.is_empty() {
389 return vec![];
390 }
391
392 let mut chunks = Vec::new();
393 let mut start = 0usize;
394
395 while start < content.len() {
396 let end = (start + CHUNK_SIZE).min(content.len());
397
398 let end = if end < content.len() {
400 let slice = &content[start..end];
401 if let Some(pos) = slice.rfind("\n\n") {
402 if pos > CHUNK_SIZE / 2 {
403 start + pos
404 } else if let Some(pos2) = slice.rfind('\n') {
405 if pos2 > CHUNK_SIZE / 2 {
406 start + pos2
407 } else {
408 end
409 }
410 } else {
411 end
412 }
413 } else if let Some(pos) = slice.rfind('\n') {
414 if pos > CHUNK_SIZE / 2 {
415 start + pos
416 } else {
417 end
418 }
419 } else {
420 end
421 }
422 } else {
423 end
424 };
425
426 let chunk = content[start..end].trim().to_string();
427 if chunk.len() >= MIN_CHUNK {
428 chunks.push(chunk);
429 }
430
431 start = if end < content.len() {
433 end.saturating_sub(CHUNK_OVERLAP)
434 } else {
435 end
436 };
437 }
438
439 chunks
440}
441
442fn drawer_id(wing: &str, room: &str, source_file: &str, chunk_index: usize) -> String {
444 let input = format!("{wing}{room}{source_file}{chunk_index}");
445 let digest = md5::compute(input.as_bytes());
446 format!("drawer_{wing}_{room}_{digest:x}")
447}
448
449#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
456 fn test_chunk_text_basic() {
457 let text = "hello world".repeat(200);
458 let chunks = chunk_text(&text);
459 assert!(!chunks.is_empty());
460 for c in &chunks {
461 assert!(c.len() <= CHUNK_SIZE + CHUNK_OVERLAP);
462 }
463 }
464
465 #[test]
466 fn test_detect_room_fallback() {
467 let rooms = vec![RoomDef {
468 name: "general".to_string(),
469 description: "all".to_string(),
470 keywords: vec![],
471 }];
472 let path = PathBuf::from("/proj/src/main.rs");
473 let room = detect_room(&path, "fn main() {}", &rooms, Path::new("/proj"));
474 assert_eq!(room, "general");
475 }
476
477 #[test]
478 fn test_detect_room_by_keyword() {
479 let rooms = vec![
480 RoomDef {
481 name: "backend".to_string(),
482 description: "".to_string(),
483 keywords: vec!["database".to_string(), "query".to_string()],
484 },
485 RoomDef {
486 name: "general".to_string(),
487 description: "".to_string(),
488 keywords: vec![],
489 },
490 ];
491 let path = PathBuf::from("/proj/main.rs");
492 let room = detect_room(
493 &path,
494 "SELECT * FROM users; database query optimization",
495 &rooms,
496 Path::new("/proj"),
497 );
498 assert_eq!(room, "backend");
499 }
500}