1pub mod cache;
2pub mod filtering;
3pub mod ingester;
4pub mod parser;
5
6pub use cache::{
7 CacheCommitStatus, CacheEntry, CacheManager, CacheStats, CachedFile, RepositoryCache,
8};
9pub use filtering::{get_default_excludes, get_excludes_for_preset, FilterConfig, FilterPreset};
10pub use ingester::{FilterStats, IngestOptions, Ingester, IngestionCallback};
11pub use parser::{
12 normalize_source_url, parse_github_url, validate_github_name, GitHubUrlType, ParsedGitHubUrl,
13};
14
15use anyhow::Result;
16use git2::Repository;
17use serde::{Deserialize, Serialize};
18use std::io::IsTerminal;
19use std::path::Path;
20use std::time::{SystemTime, UNIX_EPOCH};
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct RepositoryMetadata {
24 pub url: String,
25 pub default_branch: String,
26 pub branches: Vec<String>,
27 pub size: Option<u64>,
28 pub last_commit: Option<String>,
29 pub remote_url: Option<String>,
30}
31
32pub fn is_remote_url(source: &str) -> bool {
33 source.starts_with("https://github.com/")
34 || source.starts_with("https://gitlab.com/")
35 || source.starts_with("https://gist.github.com/")
36 || source.starts_with("https://raw.githubusercontent.com/")
37 || source.starts_with("https://gist.githubusercontent.com/")
38}
39
40pub fn clone_repository(url: &str, branch: Option<&str>) -> Result<Repository> {
41 if !is_remote_url(url) {
42 return Err(anyhow::anyhow!("Invalid or unsafe URL"));
43 }
44
45 let temp_id = SystemTime::now()
46 .duration_since(UNIX_EPOCH)
47 .unwrap()
48 .as_millis();
49 let path = std::env::temp_dir().join(format!("githem-{temp_id}"));
50
51 let mut fetch_opts = git2::FetchOptions::new();
52 let mut callbacks = git2::RemoteCallbacks::new();
53
54 callbacks.credentials(|url, username_from_url, allowed_types| {
55 if !is_remote_url(url) {
56 return Err(git2::Error::from_str(
57 "Invalid URL for credential authentication",
58 ));
59 }
60
61 if allowed_types.contains(git2::CredentialType::SSH_KEY) {
62 if let Ok(cred) = git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) {
63 return Ok(cred);
64 }
65
66 if let Ok(home) = std::env::var("HOME") {
67 let ssh_dir = Path::new(&home).join(".ssh");
68 if ssh_dir.exists() {
69 let private_key = ssh_dir.join("id_ed25519");
70 let public_key = ssh_dir.join("id_ed25519.pub");
71
72 if private_key.exists() && public_key.exists() {
73 return git2::Cred::ssh_key(
74 username_from_url.unwrap_or("git"),
75 Some(&public_key),
76 &private_key,
77 None,
78 );
79 }
80 }
81 }
82 }
83
84 if allowed_types.contains(git2::CredentialType::DEFAULT) && url.starts_with("https://") {
85 return git2::Cred::default();
86 }
87
88 Err(git2::Error::from_str(
89 "No secure authentication method available",
90 ))
91 });
92
93 if std::io::stderr().is_terminal() {
94 callbacks.transfer_progress(|stats| {
95 if stats.total_objects() > 0 {
96 eprint!(
97 "\rReceiving objects: {}% ({}/{})",
98 (100 * stats.received_objects()) / stats.total_objects(),
99 stats.received_objects(),
100 stats.total_objects()
101 );
102 }
103 true
104 });
105 }
106
107 fetch_opts.remote_callbacks(callbacks);
108 fetch_opts.depth(1);
109 fetch_opts.download_tags(git2::AutotagOption::None);
110
111 let mut builder = git2::build::RepoBuilder::new();
112 builder.fetch_options(fetch_opts);
113
114 if let Some(branch) = branch {
115 builder.branch(branch);
116 }
117
118 let repo = builder.clone(url, &path)?;
119
120 if std::io::stderr().is_terminal() {
121 eprintln!();
122 }
123
124 Ok(repo)
125}
126
127pub fn checkout_branch(repo: &Repository, branch_name: &str) -> Result<()> {
128 let (object, reference) = repo.revparse_ext(branch_name)?;
129 repo.checkout_tree(&object, None)?;
130
131 match reference {
132 Some(gref) => repo.set_head(gref.name().unwrap())?,
133 None => repo.set_head_detached(object.id())?,
134 }
135
136 Ok(())
137}
138
139pub fn glob_match(pattern: &str, path: &str) -> bool {
140 if pattern.starts_with("*.") {
141 return path.ends_with(&pattern[1..]);
142 }
143
144 if let Some(prefix) = pattern.strip_suffix("/*") {
145 return path.starts_with(prefix) && path.len() > prefix.len();
146 }
147
148 if pattern.contains('*') {
149 let parts: Vec<&str> = pattern.split('*').collect();
150 if parts.len() == 2 {
151 return path.starts_with(parts[0]) && path.ends_with(parts[1]);
152 }
153 }
154
155 path == pattern || path.starts_with(&format!("{pattern}/"))
156}
157
158pub fn estimate_tokens(content: &str) -> usize {
159 let chars = content.len();
160 let words = content.split_whitespace().count();
161 let lines = content.lines().count();
162 ((chars as f32 / 3.3 + words as f32 * 0.75) / 2.0 + lines as f32 * 0.1) as usize
163}
164
165pub fn count_files(content: &str) -> usize {
166 content.matches("=== ").count()
167}
168
169pub fn generate_tree(content: &str) -> String {
170 let mut tree = String::new();
171 tree.push_str("Repository structure:\n");
172 for line in content.lines() {
173 if line.starts_with("=== ") && line.ends_with(" ===") {
174 let path = &line[4..line.len() - 4];
175 tree.push_str(&format!("📄 {path}\n"));
176 }
177 }
178 tree
179}