Skip to main content

githem_core/
ingester.rs

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                // Handle directory patterns (ending with /)
129                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                    // Pattern without path separator - match filename only
134                    path.file_name()
135                        .and_then(|n| n.to_str())
136                        .map(|filename| glob_match(p, filename))
137                        .unwrap_or(false)
138                } else {
139                    // Pattern with path separator - match full path
140                    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            // when path_prefix is set, walk from that subtree
225            // otherwise walk from root
226            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                        // when walking a subtree, paths are relative to that subtree
245                        // prepend the prefix to get the full repository path
246                        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        // handle untracked files
266        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}