1use crate::{cache::*, clone_repository, glob_match, RepositoryMetadata};
2use anyhow::{Context, Result};
3use git2::{Repository, Status, StatusOptions};
4use serde::{Deserialize, Serialize};
5use std::io::Write;
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct IngestOptions {
11 pub include_patterns: Vec<String>,
12 pub exclude_patterns: Vec<String>,
13 pub max_file_size: usize,
14 pub include_untracked: bool,
15 pub branch: Option<String>,
16 pub path_prefix: Option<String>,
17 pub filter_preset: Option<crate::FilterPreset>,
18 pub apply_default_filters: bool,
19}
20
21impl Default for IngestOptions {
22 fn default() -> Self {
23 Self {
24 include_patterns: Vec::new(),
25 exclude_patterns: Vec::new(),
26 max_file_size: 1048576,
27 include_untracked: false,
28 branch: None,
29 path_prefix: None,
30 filter_preset: None,
31 apply_default_filters: true,
32 }
33 }
34}
35
36impl IngestOptions {
37 pub fn with_preset(preset: crate::FilterPreset) -> Self {
38 Self {
39 filter_preset: Some(preset),
40 apply_default_filters: false,
41 ..Default::default()
42 }
43 }
44
45 pub fn get_effective_excludes(&self) -> Vec<String> {
46 let mut excludes = self.exclude_patterns.clone();
47
48 if let Some(preset) = self.filter_preset {
49 excludes.extend(crate::get_excludes_for_preset(preset));
50 } else if self.apply_default_filters {
51 excludes.extend(crate::get_default_excludes());
52 }
53
54 excludes.sort();
55 excludes.dedup();
56 excludes
57 }
58}
59
60pub struct Ingester {
61 repo: Repository,
62 pub options: IngestOptions,
63 effective_excludes: Vec<String>,
64 pub cache: Option<RepositoryCache>,
65 pub cache_key: Option<String>,
66}
67
68impl Ingester {
69 pub fn new(repo: Repository, options: IngestOptions) -> Self {
70 let effective_excludes = options.get_effective_excludes();
71 Self {
72 repo,
73 options,
74 effective_excludes,
75 cache: None,
76 cache_key: None,
77 }
78 }
79
80 pub fn from_path(path: &Path, options: IngestOptions) -> Result<Self> {
81 let repo = Repository::open(path).context("Failed to open repository")?;
82 Ok(Self::new(repo, options))
83 }
84
85 pub fn from_url(url: &str, options: IngestOptions) -> Result<Self> {
86 let repo = clone_repository(url, options.branch.as_deref())?;
87 Ok(Self::new(repo, options))
88 }
89
90 pub fn from_url_cached(url: &str, options: IngestOptions) -> Result<Self> {
91 let repo = clone_repository(url, options.branch.as_deref())?;
92 let mut ingester = Self::new(repo, options.clone());
93
94 ingester.cache = RepositoryCache::new().ok();
95 ingester.cache_key = Some(RepositoryCache::generate_cache_key(
96 url,
97 options.branch.as_deref(),
98 ));
99
100 Ok(ingester)
101 }
102
103 pub fn get_filter_preset(&self) -> Option<crate::FilterPreset> {
104 self.options.filter_preset
105 }
106
107 fn should_include(&self, path: &Path) -> Result<bool> {
108 let status = self.repo.status_file(path)?;
109
110 if status.contains(Status::IGNORED) && !self.options.include_untracked {
111 return Ok(false);
112 }
113
114 if path.components().any(|c| c.as_os_str() == ".git") {
115 return Ok(false);
116 }
117
118 let path_str = path.to_string_lossy();
119
120 for pattern in &self.effective_excludes {
121 if glob_match(pattern, &path_str) {
122 return Ok(false);
123 }
124 }
125
126 if !self.options.include_patterns.is_empty() {
127 return Ok(self.options.include_patterns.iter().any(|p| {
128 if p.ends_with("/") {
130 let dir_prefix = &p[..p.len() - 1];
131 path_str.starts_with(dir_prefix) && path_str.len() > dir_prefix.len()
132 } else if !p.contains('/') {
133 path.file_name()
135 .and_then(|n| n.to_str())
136 .map(|filename| glob_match(p, filename))
137 .unwrap_or(false)
138 } else {
139 glob_match(p, &path_str)
141 }
142 }));
143 }
144
145 Ok(true)
146 }
147
148 pub fn ingest<W: Write>(&self, output: &mut W) -> Result<()> {
149 let files = self.collect_filtered_files()?;
150 let workdir = self
151 .repo
152 .workdir()
153 .context("Repository has no working directory")?;
154
155 let mut processed = 0;
156 for file in files {
157 let full_path = workdir.join(&file);
158 if full_path.exists() && full_path.is_file() {
159 self.ingest_file(&full_path, &file, output)?;
160 processed += 1;
161 }
162 }
163
164 if processed == 0 {
165 eprintln!("Warning: No files found to ingest");
166 }
167
168 Ok(())
169 }
170
171 pub fn ingest_cached<W: Write>(&mut self, output: &mut W) -> Result<()> {
172 let commit_hash = self.get_current_commit()?;
173
174 if let Some(ref mut cache) = self.cache {
175 if let Some(ref cache_key) = self.cache_key {
176 match cache.check_commit(cache_key, &commit_hash) {
177 CacheCommitStatus::Match => {
178 if let Ok(Some(cache_entry)) = cache.get(cache_key) {
179 eprintln!("✓ Using cache (commit: {})", &commit_hash[..8]);
180 return self.filter_cached_files(cache_entry, output);
181 }
182 }
183 CacheCommitStatus::Outdated => {
184 eprintln!("↻ Cache outdated, fetching new data...");
185 let _ = cache.remove(cache_key);
186 }
187 CacheCommitStatus::NotCached => {
188 eprintln!("→ No cache found, fetching repository...");
189 }
190 }
191 }
192 }
193
194 let cache_entry = self.fetch_and_cache()?;
195 self.filter_cached_files(cache_entry, output)
196 }
197
198 fn ingest_file<W: Write>(&self, path: &Path, relative: &Path, output: &mut W) -> Result<()> {
199 let metadata = std::fs::metadata(path)?;
200
201 if metadata.len() > self.options.max_file_size as u64 {
202 return Ok(());
203 }
204
205 let content = std::fs::read_to_string(path).unwrap_or_else(|_| "[Binary file]".to_string());
206
207 writeln!(output, "=== {} ===", relative.display())?;
208 writeln!(output, "{content}")?;
209 writeln!(output)?;
210
211 Ok(())
212 }
213
214 fn collect_filtered_files(&self) -> Result<Vec<PathBuf>> {
215 let head_result = self.repo.head();
216 let has_commits = head_result.is_ok();
217
218 let mut files: Vec<PathBuf> = Vec::new();
219
220 if has_commits {
221 let head = head_result?;
222 let tree = head.peel_to_tree()?;
223
224 let (tree_to_walk, is_subtree) = if let Some(prefix) = &self.options.path_prefix {
227 match tree.get_path(Path::new(prefix)) {
228 Ok(entry) => (self.repo.find_tree(entry.id())?, true),
229 Err(_) => return Ok(Vec::new()),
230 }
231 } else {
232 (tree, false)
233 };
234
235 tree_to_walk.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
236 if entry.kind() == Some(git2::ObjectType::Blob) {
237 if let Some(name) = entry.name() {
238 let path = if dir.is_empty() {
239 PathBuf::from(name)
240 } else {
241 PathBuf::from(dir).join(name)
242 };
243
244 let full_path = if is_subtree {
247 if let Some(prefix) = &self.options.path_prefix {
248 PathBuf::from(prefix).join(path)
249 } else {
250 path
251 }
252 } else {
253 path
254 };
255
256 if self.should_include(&full_path).unwrap_or(false) {
257 files.push(full_path);
258 }
259 }
260 }
261 git2::TreeWalkResult::Ok
262 })?;
263 }
264
265 if self.options.include_untracked || !has_commits {
267 let mut status_opts = StatusOptions::new();
268 status_opts.include_untracked(true);
269 status_opts.include_ignored(false);
270
271 let statuses = self.repo.statuses(Some(&mut status_opts))?;
272
273 for status in statuses.iter() {
274 if status.status().contains(Status::WT_NEW) {
275 if let Some(path) = status.path() {
276 let path_buf = PathBuf::from(path);
277 if let Some(prefix) = &self.options.path_prefix {
278 if !path.starts_with(prefix) {
279 continue;
280 }
281 }
282 if self.should_include(&path_buf).unwrap_or(false) {
283 files.push(path_buf);
284 }
285 }
286 }
287 }
288 }
289
290 files.sort();
291 files.dedup();
292 Ok(files)
293 }
294
295 fn get_current_commit(&self) -> Result<String> {
296 let head = self.repo.head()?;
297 let commit = head.peel_to_commit()?;
298 Ok(commit.id().to_string())
299 }
300
301 fn fetch_and_cache(&mut self) -> Result<CacheEntry> {
302 let workdir = self
303 .repo
304 .workdir()
305 .context("Repository has no working directory")?;
306 let commit_hash = self.get_current_commit()?;
307 let mut files = Vec::new();
308 let mut total_size = 0u64;
309
310 let all_files = self.collect_all_repository_files()?;
311
312 for file_path in all_files {
313 let full_path = workdir.join(&file_path);
314
315 if !full_path.exists() || !full_path.is_file() {
316 continue;
317 }
318
319 let metadata = std::fs::metadata(&full_path)?;
320 let content = std::fs::read(&full_path)?;
321 let is_binary = content.iter().take(8000).any(|&b| b == 0);
322
323 total_size += metadata.len();
324
325 files.push(CachedFile {
326 path: file_path,
327 content,
328 size: metadata.len(),
329 is_binary,
330 });
331 }
332
333 let cache_entry = CacheEntry {
334 repo_url: self.repo.path().to_string_lossy().to_string(),
335 branch: self
336 .options
337 .branch
338 .clone()
339 .unwrap_or_else(|| "HEAD".to_string()),
340 commit_hash: commit_hash.clone(),
341 files: files.clone(),
342 metadata: CacheMetadata {
343 total_files: files.len(),
344 total_size,
345 tree_hash: commit_hash.clone(),
346 cache_version: "1.0.0".to_string(),
347 },
348 created_at: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(),
349 last_accessed: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(),
350 };
351
352 if let Some(ref mut cache) = self.cache {
353 if let Some(ref cache_key) = self.cache_key {
354 cache.put(cache_key.clone(), cache_entry.clone())?;
355 eprintln!(
356 "✓ Cached {} files ({:.2} MB)",
357 files.len(),
358 total_size as f64 / 1_048_576.0
359 );
360 }
361 }
362
363 Ok(cache_entry)
364 }
365
366 fn collect_all_repository_files(&self) -> Result<Vec<PathBuf>> {
367 let mut files = Vec::new();
368
369 let head = self.repo.head()?;
370 let tree = head.peel_to_tree()?;
371
372 tree.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
373 if entry.kind() == Some(git2::ObjectType::Blob) {
374 if let Some(name) = entry.name() {
375 let path = if dir.is_empty() {
376 PathBuf::from(name)
377 } else {
378 PathBuf::from(dir).join(name)
379 };
380 files.push(path);
381 }
382 }
383 git2::TreeWalkResult::Ok
384 })?;
385
386 Ok(files)
387 }
388
389 fn filter_cached_files<W: Write>(&self, cache_entry: CacheEntry, output: &mut W) -> Result<()> {
390 let mut processed = 0;
391 let mut filtered_size = 0u64;
392
393 for cached_file in &cache_entry.files {
394 if !self.should_include(&cached_file.path)? {
395 continue;
396 }
397
398 if cached_file.size > self.options.max_file_size as u64 {
399 continue;
400 }
401
402 let content = if cached_file.is_binary {
403 "[Binary file]".to_string()
404 } else {
405 String::from_utf8_lossy(&cached_file.content).to_string()
406 };
407
408 writeln!(output, "=== {} ===", cached_file.path.display())?;
409 writeln!(output, "{}", content)?;
410 writeln!(output)?;
411
412 processed += 1;
413 filtered_size += cached_file.size;
414 }
415
416 eprintln!(
417 "→ Filtered: {} files ({:.2} MB) from {} total",
418 processed,
419 filtered_size as f64 / 1_048_576.0,
420 cache_entry.metadata.total_files
421 );
422
423 Ok(())
424 }
425
426 pub fn get_filter_stats(&self) -> Result<FilterStats> {
427 let workdir = self
428 .repo
429 .workdir()
430 .context("Repository has no working directory")?;
431 let all_files = self.collect_all_repository_files()?;
432
433 let mut stats = FilterStats::default();
434 stats.total_files = all_files.len();
435
436 for file in all_files {
437 let full_path = workdir.join(&file);
438
439 if let Ok(metadata) = std::fs::metadata(&full_path) {
440 stats.total_size += metadata.len();
441
442 if self.should_include(&file)? {
443 stats.included_files += 1;
444 stats.included_size += metadata.len();
445 } else {
446 stats.excluded_files += 1;
447 stats.excluded_size += metadata.len();
448 }
449 }
450 }
451
452 Ok(stats)
453 }
454
455 pub fn generate_diff(&self, base: &str, head: &str) -> Result<String> {
456 let repo = &self.repo;
457
458 let (base_object, _) = repo.revparse_ext(base)?;
459 let (head_object, _) = repo.revparse_ext(head)?;
460
461 let base_commit = base_object.peel_to_commit()?;
462 let head_commit = head_object.peel_to_commit()?;
463
464 let base_tree = base_commit.tree()?;
465 let head_tree = head_commit.tree()?;
466
467 let mut diff_opts = git2::DiffOptions::new();
468 let diff =
469 repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut diff_opts))?;
470
471 let mut output = String::new();
472 output.push_str(&format!("# Comparing {} to {}\n\n", base, head));
473
474 let stats = diff.stats()?;
475 output.push_str(&format!("Files changed: {}\n", stats.files_changed()));
476 output.push_str(&format!("Insertions: {}\n", stats.insertions()));
477 output.push_str(&format!("Deletions: {}\n\n", stats.deletions()));
478
479 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
480 let content = std::str::from_utf8(line.content()).unwrap_or("[binary]");
481 output.push_str(content);
482 true
483 })?;
484
485 Ok(output)
486 }
487
488 pub fn get_metadata(&self) -> Result<RepositoryMetadata> {
489 let repo = &self.repo;
490
491 let default_branch = repo
492 .head()
493 .ok()
494 .and_then(|h| h.shorthand().map(String::from))
495 .unwrap_or_else(|| "main".to_string());
496
497 let mut branches = Vec::new();
498 for (branch, _) in (repo.branches(Some(git2::BranchType::Local))?).flatten() {
499 if let Ok(Some(name)) = branch.name() {
500 branches.push(name.to_string());
501 }
502 }
503
504 let remote_url = repo
505 .find_remote("origin")
506 .ok()
507 .and_then(|r| r.url().map(String::from));
508
509 let last_commit = repo
510 .head()
511 .ok()
512 .and_then(|h| h.peel_to_commit().ok())
513 .map(|c| {
514 format!(
515 "{} - {}",
516 c.id().to_string().chars().take(8).collect::<String>(),
517 c.summary().unwrap_or("No message")
518 )
519 });
520
521 let size = repo.workdir().and_then(|w| {
522 walkdir::WalkDir::new(w)
523 .into_iter()
524 .filter_map(|e| e.ok())
525 .filter_map(|e| e.metadata().ok())
526 .map(|m| m.len())
527 .reduce(|a, b| a + b)
528 });
529
530 Ok(RepositoryMetadata {
531 url: remote_url.clone().unwrap_or_default(),
532 default_branch,
533 branches,
534 size,
535 last_commit,
536 remote_url,
537 })
538 }
539}
540
541#[derive(Debug, Default, Clone, Serialize, Deserialize)]
542pub struct FilterStats {
543 pub total_files: usize,
544 pub included_files: usize,
545 pub excluded_files: usize,
546 pub total_size: u64,
547 pub included_size: u64,
548 pub excluded_size: u64,
549 pub excluded_by_filter: usize,
550}
551
552impl FilterStats {
553 pub fn inclusion_rate(&self) -> f64 {
554 if self.total_files == 0 {
555 0.0
556 } else {
557 self.included_files as f64 / self.total_files as f64
558 }
559 }
560
561 pub fn size_reduction(&self) -> f64 {
562 if self.total_size == 0 {
563 0.0
564 } else {
565 self.excluded_size as f64 / self.total_size as f64
566 }
567 }
568}
569
570pub trait IngestionCallback: Send + Sync {
571 fn on_progress(&mut self, _stage: &str, _message: &str) {}
572 fn on_file(&mut self, _path: &Path, _content: &str) {}
573 fn on_complete(&mut self, _files: usize, _bytes: usize) {}
574 fn on_error(&mut self, _error: &str) {}
575}