#![cfg(all(feature = "crawl", feature = "github"))]
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use link_r::facade::{LinkIndex, PageChange};
use link_r::UrlKey;
struct FakeGithub {
port: u16,
tree_calls: Arc<AtomicU32>,
blob_fetches: Arc<AtomicU32>,
auth_seen: Arc<Mutex<Vec<String>>>,
}
type Files = HashMap<String, (String, String)>;
fn tree_json(files: &Files, truncated: bool) -> String {
let entries: Vec<String> = files
.iter()
.map(|(path, (sha, body))| {
format!(
r#"{{"path":"{path}","mode":"100644","type":"blob","sha":"{sha}","size":{}}}"#,
body.len()
)
})
.collect();
format!(
r#"{{"sha":"root","truncated":{truncated},"tree":[{}]}}"#,
entries.join(",")
)
}
fn serve(files: Arc<Mutex<Files>>, truncated: bool) -> FakeGithub {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let tree_calls = Arc::new(AtomicU32::new(0));
let blob_fetches = Arc::new(AtomicU32::new(0));
let auth_seen = Arc::new(Mutex::new(Vec::new()));
let (tc, bf, auth) = (tree_calls.clone(), blob_fetches.clone(), auth_seen.clone());
thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let mut buf = [0u8; 8192];
let n = stream.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]);
let path = req.split_whitespace().nth(1).unwrap_or("/").to_string();
if let Some(a) = req.lines().find_map(|l| {
l.strip_prefix("authorization: ")
.or_else(|| l.strip_prefix("Authorization: "))
}) {
auth.lock().unwrap().push(format!("{path} {}", a.trim()));
}
let files = files.lock().unwrap();
let respond = |body: &str, ctype: &str| {
format!(
"HTTP/1.1 200 OK\r\ncontent-type: {ctype}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
};
let response = if path.contains("/git/trees/") {
tc.fetch_add(1, Ordering::Relaxed);
respond(&tree_json(&files, truncated), "application/json")
} else if path.starts_with("/repos/") {
respond(r#"{"default_branch":"main"}"#, "application/json")
} else if let Some(rest) = path.strip_prefix("/raw/o/r/main/") {
match files.get(rest) {
Some((_, body)) => {
bf.fetch_add(1, Ordering::Relaxed);
respond(body, "text/plain; charset=utf-8")
}
None => {
"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"
.into()
}
}
} else {
"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n".into()
};
let _ = stream.write_all(response.as_bytes());
}
});
FakeGithub {
port,
tree_calls,
blob_fetches,
auth_seen,
}
}
fn corpus() -> Files {
[
(
"stacks/README.md".to_string(),
(
"sha-r1".to_string(),
"# Stacks\n\nexample corpus index words".to_string(),
),
),
(
"stacks/big_query/tables/Pulumi.yaml".to_string(),
(
"sha-y1".to_string(),
"name: bq-tables\nruntime: yaml\ndescription: bigquery table example".to_string(),
),
),
(
"stacks/big_query/tables/README.md".to_string(),
(
"sha-m1".to_string(),
"# BigQuery Tables\n\nHow to declare a bigquery table stack".to_string(),
),
),
]
.into()
}
fn bases(port: u16) -> (String, String) {
(
format!("http://127.0.0.1:{port}"),
format!("http://127.0.0.1:{port}/raw"),
)
}
#[tokio::test]
async fn unchanged_repo_costs_exactly_one_request() {
let files = Arc::new(Mutex::new(corpus()));
let gh = serve(files, false);
let (api, raw) = bases(gh.port);
let mut first = LinkIndex::in_memory().unwrap();
let report = first
.update_github("github:o/r@main//stacks")
.unwrap()
.bases(&api, &raw)
.token("sekrit")
.run()
.await
.unwrap();
assert_eq!(report.added, 3, "{report:?}");
assert_eq!(gh.tree_calls.load(Ordering::Relaxed), 1);
assert_eq!(gh.blob_fetches.load(Ordering::Relaxed), 3);
let seeds: Vec<(UrlKey, compact_str::CompactString)> = first
.export()
.unwrap()
.map(|d| {
(
d.meta.url_key,
d.meta.etag.clone().expect("blob sha stored as etag"),
)
})
.collect();
assert_eq!(seeds.len(), 3);
drop(first);
let mut second = LinkIndex::in_memory().unwrap();
let report = second
.update_github("github:o/r@main//stacks")
.unwrap()
.bases(&api, &raw)
.token("sekrit")
.validators(seeds.clone())
.run()
.await
.unwrap();
assert_eq!(
gh.tree_calls.load(Ordering::Relaxed),
2,
"one more tree call"
);
assert_eq!(
gh.blob_fetches.load(Ordering::Relaxed),
3,
"ZERO new blob fetches"
);
assert_eq!(report.added, 0, "{report:?}");
assert_eq!(
report.unchanged, 3,
"every skipped file is reported: {report:?}"
);
assert!(report
.pages
.iter()
.all(|p| p.change == PageChange::Unchanged));
let auth = gh.auth_seen.lock().unwrap();
assert!(auth
.iter()
.any(|a| a.contains("/git/trees/") && a.contains("Bearer sekrit")));
assert!(auth
.iter()
.any(|a| a.contains("/raw/") && a.contains("Bearer sekrit")));
}
#[tokio::test]
async fn a_one_file_change_costs_one_tree_call_and_one_fetch() {
let files = Arc::new(Mutex::new(corpus()));
let gh = serve(files.clone(), false);
let (api, raw) = bases(gh.port);
let mut first = LinkIndex::in_memory().unwrap();
first
.update_github("github:o/r@main//stacks")
.unwrap()
.bases(&api, &raw)
.run()
.await
.unwrap();
let seeds: Vec<(UrlKey, compact_str::CompactString)> = first
.export()
.unwrap()
.map(|d| (d.meta.url_key, d.meta.etag.clone().unwrap()))
.collect();
drop(first);
let fetched_before = gh.blob_fetches.load(Ordering::Relaxed);
files.lock().unwrap().insert(
"stacks/big_query/tables/README.md".into(),
(
"sha-m2".into(),
"# BigQuery Tables\n\nNow with partitioned table guidance".into(),
),
);
let mut second = LinkIndex::in_memory().unwrap();
let report = second
.update_github("github:o/r@main//stacks")
.unwrap()
.bases(&api, &raw)
.validators(seeds)
.run()
.await
.unwrap();
assert_eq!(
gh.blob_fetches.load(Ordering::Relaxed),
fetched_before + 1,
"exactly the changed file transfers"
);
assert_eq!(
report.added, 1,
"fresh index: the changed file arrives as added: {report:?}"
);
assert_eq!(report.unchanged, 2, "{report:?}");
let hits = second
.search("partitioned table guidance", 5)
.await
.unwrap();
assert!(
hits[0].url.ends_with("/stacks/big_query/tables/README.md"),
"{hits:?}"
);
}
#[tokio::test]
async fn a_bare_spec_resolves_the_default_branch_first() {
let files = Arc::new(Mutex::new(corpus()));
let gh = serve(files, false);
let (api, raw) = bases(gh.port);
let mut idx = LinkIndex::in_memory().unwrap();
let report = idx
.update_github("github:o/r") .unwrap()
.bases(&api, &raw)
.run()
.await
.unwrap();
assert_eq!(report.added, 3, "{report:?}");
}
#[tokio::test]
async fn a_truncated_tree_is_a_typed_error_not_a_partial_index() {
let files = Arc::new(Mutex::new(corpus()));
let gh = serve(files, true); let (api, raw) = bases(gh.port);
let mut idx = LinkIndex::in_memory().unwrap();
let err = idx
.update_github("github:o/r@main//stacks")
.unwrap()
.bases(&api, &raw)
.run()
.await
.unwrap_err();
assert!(err.to_string().contains("truncated"), "{err}");
assert_eq!(idx.len(), 0, "a partial listing must index nothing");
}
#[tokio::test]
async fn depth_and_extension_filters_confine_the_sync() {
let files = Arc::new(Mutex::new(corpus()));
let gh = serve(files, false);
let (api, raw) = bases(gh.port);
let mut idx = LinkIndex::in_memory().unwrap();
let report = idx
.update_github("github:o/r@main//stacks")
.unwrap()
.bases(&api, &raw)
.depth(0)
.run()
.await
.unwrap();
assert_eq!(report.added, 1, "{report:?}");
let mut idx = LinkIndex::in_memory().unwrap();
let report = idx
.update_github("github:o/r@main//stacks")
.unwrap()
.bases(&api, &raw)
.accept_extension("md")
.run()
.await
.unwrap();
assert_eq!(report.added, 2, "{report:?}");
}