use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::process::Command;
use patina::forge::{ForgeWriter, GitHubWriter};
use patina::paths;
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Registry {
pub version: u32,
#[serde(default)]
pub projects: HashMap<String, ProjectEntry>,
#[serde(default)]
pub repos: HashMap<String, RepoEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectEntry {
pub path: String,
#[serde(rename = "type")]
pub project_type: String,
pub registered: String,
#[serde(default)]
pub domains: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoEntry {
#[serde(skip)]
#[serde(default)]
pub name: String,
pub path: String,
pub github: String,
#[serde(default)]
pub contrib: bool,
#[serde(default)]
pub fork: Option<String>,
pub registered: String,
#[serde(default)]
pub synced_commit: Option<String>,
#[serde(default)]
pub domains: Vec<String>,
}
impl Registry {
pub fn load() -> Result<Self> {
let path = paths::registry_path();
if !path.exists() {
return Ok(Registry {
version: 1,
..Default::default()
});
}
let contents = fs::read_to_string(&path)
.with_context(|| format!("Failed to read registry: {}", path.display()))?;
let registry: Registry = serde_yaml::from_str(&contents)
.with_context(|| format!("Failed to parse registry: {}", path.display()))?;
let cache_prefix = paths::repos::cache_dir();
for (name, entry) in ®istry.repos {
validate_repo_path(&entry.path, &cache_prefix, name)?;
}
Ok(registry)
}
pub fn save(&self) -> Result<()> {
let path = paths::registry_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let contents = serde_yaml::to_string(self)?;
fs::write(&path, contents)?;
Ok(())
}
}
pub fn add_repo(url: &str, contrib: bool, with_issues: bool, no_oxidize: bool) -> Result<()> {
let (owner, repo_name) = parse_github_url(url)?;
let github = format!("{}/{}", owner, repo_name);
println!("🚀 Adding repository: {}\n", github);
let mut registry = Registry::load()?;
if registry.repos.contains_key(&github) {
let existing = ®istry.repos[&github];
if contrib && !existing.contrib {
println!("📌 Repository exists, upgrading to contributor mode...");
return upgrade_to_contrib(&github, &mut registry);
}
bail!(
"Repository '{}' already registered. Use 'patina repo update {}' to refresh.",
github,
github
);
}
let repos_path = paths::repos::cache_dir();
fs::create_dir_all(&repos_path)?;
let repo_path = repos_path.join(&github);
println!("📥 Cloning {}...", github);
clone_repo(url, &repo_path)?;
println!("🌿 Creating patina branch...");
create_patina_branch(&repo_path)?;
println!("📁 Scaffolding .patina structure...");
scaffold_patina(&repo_path)?;
println!("🔍 Scraping codebase...");
let event_count = scrape_repo(&repo_path)?;
let issue_count = if with_issues {
println!("🐙 Fetching GitHub issues...");
match scrape_github_issues(&repo_path, &github) {
Ok(count) => {
println!(" 💰 Indexed {} issues", count);
count
}
Err(e) => {
println!(
" ⚠️ GitHub scrape failed: {}. Continuing without issues.",
e
);
0
}
}
} else {
0
};
let fork = if contrib {
println!("🍴 Creating fork...");
match create_fork(&repo_path, &owner, &repo_name) {
Ok(fork_name) => Some(fork_name),
Err(e) => {
println!("⚠️ Fork creation failed: {}. Continuing without fork.", e);
None
}
}
} else {
None
};
let oxidize_success = if no_oxidize {
println!("\n⏭️ Skipping semantic indices (--no-oxidize)");
false
} else {
println!("\n🧪 Building semantic indices...");
match oxidize_repo(&repo_path) {
Ok(()) => {
println!(" ✅ Semantic search enabled");
true
}
Err(e) => {
println!(" ⚠️ Oxidize failed: {}. Semantic search unavailable.", e);
println!(
" Run 'patina repo update {} --oxidize' to retry.",
github
);
false
}
}
};
let timestamp = chrono::Utc::now().to_rfc3339();
let domains = detect_domains(&repo_path);
let synced_commit = get_head_sha(&repo_path);
registry.repos.insert(
github.clone(),
RepoEntry {
name: github.clone(),
path: repo_path.to_string_lossy().to_string(),
github: github.clone(),
contrib: fork.is_some(),
fork,
registered: timestamp,
synced_commit,
domains,
},
);
registry.save()?;
let search_mode = if oxidize_success {
"semantic + lexical"
} else {
"lexical only"
};
println!("\n✅ Repository added successfully!");
println!(" Path: {}", repo_path.display());
println!(" Code events: {}", event_count);
println!(" Search: {}", search_mode);
if issue_count > 0 {
println!(" GitHub issues: {}", issue_count);
println!(
"\n Query with: patina scry \"your query\" --repo {} --include-issues",
github
);
} else {
println!(
"\n Query with: patina scry \"your query\" --repo {}",
github
);
}
Ok(())
}
pub fn list_repos() -> Result<Vec<RepoEntry>> {
let registry = Registry::load()?;
let mut repos: Vec<RepoEntry> = registry
.repos
.into_iter()
.map(|(name, mut entry)| {
entry.name = name;
entry
})
.collect();
repos.sort_by(|a, b| a.name.cmp(&b.name));
Ok(repos)
}
pub fn update_repo(name: &str, oxidize: bool, with_issues: bool) -> Result<()> {
let mut registry = Registry::load()?;
let entry = registry
.repos
.get(name)
.ok_or_else(|| anyhow::anyhow!("Repository '{}' not found", name))?
.clone();
println!("🔄 Updating {}...\n", name);
let repo_path = Path::new(&entry.path);
patina::project::create_uid_if_missing(repo_path)?;
println!("📥 Pulling latest changes...");
git_pull(repo_path)?;
println!("🔍 Re-scraping codebase...");
let event_count = scrape_repo(repo_path)?;
let issue_count = if with_issues {
let github = &entry.github;
println!("🐙 Fetching GitHub issues...");
match scrape_github_issues(repo_path, github) {
Ok(count) => {
println!(" Indexed {} issues/PRs", count);
count
}
Err(e) => {
println!(
" ⚠️ GitHub scrape failed: {}. Continuing without issues.",
e
);
0
}
}
} else {
0
};
if oxidize {
println!("\n🧪 Building semantic indices...");
oxidize_repo(repo_path)?;
}
if let Some(entry) = registry.repos.get_mut(name) {
entry.synced_commit = get_head_sha(repo_path);
registry.save()?;
}
println!("\n✅ Updated {} ({} events", name, event_count);
if issue_count > 0 {
println!(" + {} issues/PRs indexed", issue_count);
}
if oxidize {
println!(" Semantic indices built - scry will use vector search");
}
Ok(())
}
pub fn update_all_repos(oxidize: bool, with_issues: bool) -> Result<()> {
let repos = list_repos()?;
if repos.is_empty() {
println!("No repositories to update.");
return Ok(());
}
println!("🔄 Updating {} repositories...\n", repos.len());
let mut success = 0;
for repo in &repos {
print!(" {} ... ", repo.name);
match update_repo(&repo.name, oxidize, with_issues) {
Ok(_) => {
println!("✓");
success += 1;
}
Err(e) => println!("✗ {}", e),
}
}
println!("\n✅ Updated {}/{} repositories", success, repos.len());
Ok(())
}
pub fn remove_repo(name: &str) -> Result<()> {
let mut registry = Registry::load()?;
let entry = registry
.repos
.remove(name)
.ok_or_else(|| anyhow::anyhow!("Repository '{}' not found", name))?;
println!("🗑️ Removing {}...", name);
let repo_path = Path::new(&entry.path);
if repo_path.exists() {
fs::remove_dir_all(repo_path)
.with_context(|| format!("Failed to remove directory: {}", repo_path.display()))?;
}
registry.save()?;
println!("✅ Removed {}", name);
Ok(())
}
pub fn show_repo(name: &str) -> Result<()> {
let registry = Registry::load()?;
let entry = registry
.repos
.get(name)
.ok_or_else(|| anyhow::anyhow!("Repository '{}' not found", name))?;
let repo_path = Path::new(&entry.path);
println!("📚 Repository: {}\n", name);
println!(" GitHub: {}", entry.github);
println!(" Path: {}", entry.path);
println!(" Contrib: {}", if entry.contrib { "Yes" } else { "No" });
if let Some(fork) = &entry.fork {
println!(" Fork: {}", fork);
}
println!(" Domains: {}", entry.domains.join(", "));
println!(" Registered: {}", format_timestamp(&entry.registered));
if let Some(upstream) = get_upstream_head(repo_path) {
let commit_date =
get_commit_date_relative(repo_path, &upstream).unwrap_or_else(|| "unknown".to_string());
println!(" Last commit: {}", commit_date);
let synced = entry
.synced_commit
.as_ref()
.map(|s| s == &upstream)
.unwrap_or(false);
if synced {
println!(" Synced: ✓ up to date");
} else {
let behind = count_commits_behind(repo_path, entry.synced_commit.as_deref());
if behind > 0 {
println!(" Synced: ⚠ {} commits behind", behind);
} else {
println!(" Synced: ⚠ needs update");
}
}
}
let db_path = repo_path.join(".patina/local/data/patina.db");
if db_path.exists() {
if let Ok(conn) = rusqlite::Connection::open(&db_path) {
if let Ok(count) = conn.query_row("SELECT COUNT(*) FROM eventlog", [], |row| {
row.get::<_, i64>(0)
}) {
println!(" Events: {}", count);
}
}
}
Ok(())
}
pub fn get_repo_db_path(name: &str) -> Result<String> {
let registry = Registry::load()?;
let entry = registry
.repos
.get(name)
.ok_or_else(|| anyhow::anyhow!("Repository '{}' not found", name))?;
let db_path = Path::new(&entry.path).join(".patina/local/data/patina.db");
if !db_path.exists() {
bail!(
"Database not found for '{}'. Run 'patina repo update {}' to rebuild.",
name,
name
);
}
Ok(db_path.to_string_lossy().to_string())
}
pub fn get_repo_path(name: &str) -> Result<std::path::PathBuf> {
let registry = Registry::load()?;
let entry = registry.repos.get(name).ok_or_else(|| {
anyhow::anyhow!(
"Repository '{}' not found. Use 'patina repo list' to see registered repos.",
name
)
})?;
let path = std::path::PathBuf::from(&entry.path);
if !path.exists() {
bail!(
"Repository path '{}' not found. It may have been moved or deleted.",
entry.path
);
}
Ok(path)
}
fn parse_github_url(url: &str) -> Result<(String, String)> {
let url = url.trim();
if !url.contains("://") && !url.contains('@') && url.contains('/') {
let parts: Vec<&str> = url.split('/').collect();
if parts.len() == 2 {
return Ok((
parts[0].to_string(),
parts[1].trim_end_matches(".git").to_string(),
));
}
}
if url.starts_with("git@github.com:") {
let path = url.trim_start_matches("git@github.com:");
let parts: Vec<&str> = path.split('/').collect();
if parts.len() >= 2 {
return Ok((
parts[0].to_string(),
parts[1].trim_end_matches(".git").to_string(),
));
}
}
if url.contains("github.com") {
let parts: Vec<&str> = url.split('/').collect();
if parts.len() >= 5 {
let owner = parts[3].to_string();
let repo = parts[4].trim_end_matches(".git").to_string();
return Ok((owner, repo));
}
}
bail!(
"Could not parse GitHub URL: {}. Expected format: https://github.com/owner/repo",
url
)
}
fn clone_repo(url: &str, target: &Path) -> Result<()> {
if target.exists() {
bail!("Target directory already exists: {}", target.display());
}
let clone_url = if url.contains("://") || url.contains('@') {
url.to_string()
} else {
format!("https://github.com/{}", url)
};
let output = Command::new("git")
.args(["clone", &clone_url, &target.to_string_lossy()])
.output()
.context("Failed to execute git clone")?;
if !output.status.success() {
bail!(
"git clone failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn create_patina_branch(repo_path: &Path) -> Result<()> {
let output = Command::new("git")
.args(["branch", "--list", "patina"])
.current_dir(repo_path)
.output()?;
let branch_exists = !String::from_utf8_lossy(&output.stdout).trim().is_empty();
if branch_exists {
let output = Command::new("git")
.args(["checkout", "patina"])
.current_dir(repo_path)
.output()?;
if !output.status.success() {
bail!(
"Failed to checkout patina branch: {}",
String::from_utf8_lossy(&output.stderr)
);
}
} else {
let output = Command::new("git")
.args(["checkout", "-b", "patina"])
.current_dir(repo_path)
.output()?;
if !output.status.success() {
bail!(
"Failed to create patina branch: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
Ok(())
}
fn scaffold_patina(repo_path: &Path) -> Result<()> {
let patina_dir = repo_path.join(".patina");
let data_dir = patina_dir.join("data");
fs::create_dir_all(&data_dir)?;
patina::project::create_uid_if_missing(repo_path)?;
let config_path = patina_dir.join("config.toml");
if !config_path.exists() {
fs::write(
&config_path,
r#"# Patina configuration for external repo
[project]
type = "external"
[scrape]
include = ["**/*.rs", "**/*.cairo", "**/*.sol", "**/*.ts", "**/*.js", "**/*.py", "**/*.go"]
exclude = ["target/", "node_modules/", ".git/"]
[embeddings]
model = "e5-base-v2"
"#,
)?;
}
let sessions_dir = repo_path.join("layer/sessions");
fs::create_dir_all(&sessions_dir)?;
let gitignore_path = repo_path.join(".gitignore");
let gitignore_content = if gitignore_path.exists() {
fs::read_to_string(&gitignore_path)?
} else {
String::new()
};
if !gitignore_content.contains(".patina/local") {
let addition = "\n# Patina local state (derived, not committed)\n.patina/local/\n";
fs::write(
&gitignore_path,
format!("{}{}", gitignore_content, addition),
)?;
}
Ok(())
}
fn scrape_repo(repo_path: &Path) -> Result<usize> {
use crate::commands::scrape;
let original_dir = std::env::current_dir()?;
std::env::set_current_dir(repo_path)?;
let config = scrape::ScrapeConfig::new(true);
let stats = scrape::code::run(config)?;
let _ = scrape::git::run(true);
std::env::set_current_dir(original_dir)?;
Ok(stats.items_processed)
}
fn oxidize_repo(repo_path: &Path) -> Result<()> {
use crate::commands::oxidize;
use std::os::unix::fs::symlink;
let original_dir = std::env::current_dir()?;
let resources_path = original_dir.join("resources");
std::env::set_current_dir(repo_path)?;
let config_path = repo_path.join(".patina/config.toml");
if config_path.exists() {
let config_content = fs::read_to_string(&config_path)?;
if !config_content.contains("[embeddings]") {
println!(" Adding embeddings config...");
let updated = format!("{}\n[embeddings]\nmodel = \"e5-base-v2\"\n", config_content);
fs::write(&config_path, updated)?;
}
}
let recipe_path = repo_path.join(".patina/oxidize.yaml");
if !recipe_path.exists() {
println!(" Creating oxidize.yaml recipe (dependency + temporal + semantic)...");
let recipe_content = r#"# Oxidize Recipe for reference repo
# Reference repos support all three dimensions:
# - dependency: call graph from AST (functions that call each other)
# - temporal: co-change history from git (files that change together)
# - semantic: commit messages as training signal (NL → code similarity)
version: 1
embedding_model: e5-base-v2
projections:
# Dependency projection - functions that call each other are related
dependency:
layers: [768, 1024, 256]
epochs: 10
batch_size: 32
# Temporal projection - files that change together are related
temporal:
layers: [768, 1024, 256]
epochs: 10
batch_size: 32
# Semantic projection - commit messages train NL → code similarity
semantic:
layers: [768, 1024, 256]
epochs: 10
batch_size: 32
"#;
fs::write(&recipe_path, recipe_content)?;
}
let repo_resources = repo_path.join("resources");
if !repo_resources.exists() && resources_path.exists() {
println!(" Linking model resources...");
symlink(&resources_path, &repo_resources).context("Failed to create resources symlink")?;
}
let result = oxidize::oxidize();
if repo_resources.is_symlink() {
let _ = fs::remove_file(&repo_resources);
}
std::env::set_current_dir(original_dir)?;
result
}
fn scrape_github_issues(repo_path: &Path, _github: &str) -> Result<usize> {
use crate::commands::scrape::forge::{run, ForgeScrapeConfig};
let config = ForgeScrapeConfig {
force: true,
working_dir: Some(repo_path.to_path_buf()),
..Default::default()
};
let stats = run(config)?;
Ok(stats.items_processed)
}
fn git_pull(repo_path: &Path) -> Result<()> {
let _ = Command::new("git")
.args(["stash"])
.current_dir(repo_path)
.output();
let output = Command::new("git")
.args(["pull", "origin", "HEAD"])
.current_dir(repo_path)
.output()
.context("Failed to execute git pull")?;
if !output.status.success() {
let output2 = Command::new("git")
.args(["pull", "origin", "main"])
.current_dir(repo_path)
.output();
if output2.is_err() || !output2.unwrap().status.success() {
let _ = Command::new("git")
.args(["pull", "origin", "master"])
.current_dir(repo_path)
.output();
}
}
Ok(())
}
fn create_fork(repo_path: &Path, _owner: &str, _repo: &str) -> Result<String> {
let writer = GitHubWriter;
let fork_url = writer.fork(repo_path)?;
let _ = Command::new("git")
.args(["remote", "add", "fork", &fork_url])
.current_dir(repo_path)
.output();
Ok(fork_url)
}
fn upgrade_to_contrib(name: &str, registry: &mut Registry) -> Result<()> {
let entry = registry
.repos
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("Repository '{}' not found", name))?;
let repo_path = Path::new(&entry.path);
println!("🍴 Creating fork...");
match create_fork(repo_path, "", "") {
Ok(fork_name) => {
entry.contrib = true;
entry.fork = Some(fork_name);
registry.save()?;
println!("✅ Upgraded to contributor mode");
Ok(())
}
Err(e) => bail!("Failed to create fork: {}", e),
}
}
fn detect_domains(repo_path: &Path) -> Vec<String> {
let mut domains = Vec::new();
let extensions_to_domains = [
("rs", "rust"),
("cairo", "cairo"),
("sol", "solidity"),
("ts", "typescript"),
("tsx", "typescript"),
("js", "javascript"),
("py", "python"),
("go", "go"),
("java", "java"),
("cpp", "cpp"),
("c", "c"),
];
for (ext, domain) in extensions_to_domains {
let pattern = format!("**/*.{}", ext);
if let Ok(entries) = glob::glob(&repo_path.join(&pattern).to_string_lossy()) {
if entries.take(1).count() > 0 {
domains.push(domain.to_string());
}
}
}
if repo_path.join("Scarb.toml").exists() && !domains.contains(&"starknet".to_string()) {
domains.push("starknet".to_string());
}
if repo_path.join("foundry.toml").exists() && !domains.contains(&"ethereum".to_string()) {
domains.push("ethereum".to_string());
}
if repo_path.join("Cargo.toml").exists() && !domains.contains(&"rust".to_string()) {
domains.push("rust".to_string());
}
domains.sort();
domains.dedup();
domains
}
fn get_head_sha(repo_path: &Path) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn get_commit_date_relative(repo_path: &Path, commit: &str) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["log", "-1", "--format=%cr", commit])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn count_commits_behind(repo_path: &Path, synced_commit: Option<&str>) -> usize {
let Some(synced) = synced_commit else {
return 0;
};
for remote_ref in ["origin/HEAD", "origin/main", "origin/master"] {
if let Ok(output) = Command::new("git")
.arg("-C")
.arg(repo_path)
.args([
"rev-list",
"--count",
&format!("{}..{}", synced, remote_ref),
])
.output()
{
if output.status.success() {
let count_str = String::from_utf8_lossy(&output.stdout);
return count_str.trim().parse().unwrap_or(0);
}
}
}
0
}
fn get_upstream_head(repo_path: &Path) -> Option<String> {
let _ = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["fetch", "origin", "--quiet"])
.output();
for remote_ref in ["origin/HEAD", "origin/main", "origin/master"] {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["rev-parse", remote_ref])
.output()
.ok()?;
if output.status.success() {
return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
}
}
None
}
fn format_timestamp(iso: &str) -> String {
use chrono::{DateTime, Utc};
let Ok(dt) = iso.parse::<DateTime<Utc>>() else {
return iso.to_string(); };
let now = Utc::now();
let duration = now.signed_duration_since(dt);
if duration.num_days() > 30 {
dt.format("%Y-%m-%d").to_string()
} else if duration.num_days() > 0 {
format!("{} days ago", duration.num_days())
} else if duration.num_hours() > 0 {
format!("{} hours ago", duration.num_hours())
} else if duration.num_minutes() > 0 {
format!("{} minutes ago", duration.num_minutes())
} else {
"just now".to_string()
}
}
pub fn check_repo_status(repo_path: &str, synced_commit: Option<&str>) -> String {
let path = Path::new(repo_path);
if !path.exists() {
return "✗ not found".to_string();
}
let Some(upstream) = get_upstream_head(path) else {
return "✗ fetch failed".to_string();
};
let commit_date = get_commit_date_relative(path, &upstream).unwrap_or_else(|| "?".to_string());
let is_synced = synced_commit.map(|s| s == upstream).unwrap_or(false);
if is_synced {
format!("✓ synced ({})", commit_date)
} else {
let behind = count_commits_behind(path, synced_commit);
if behind > 0 {
format!("⚠ {} behind ({})", behind, commit_date)
} else {
format!("⚠ needs sync ({})", commit_date)
}
}
}
fn validate_repo_path(path: &str, cache_prefix: &Path, repo_name: &str) -> Result<()> {
let repo_path = Path::new(path);
if path.contains("..") {
bail!(
"Registry path for '{}' contains path traversal: {}",
repo_name,
path
);
}
if !repo_path.exists() {
let prefix_str = cache_prefix.to_string_lossy();
if !path.starts_with(prefix_str.as_ref()) {
bail!(
"Registry path for '{}' is outside cache directory: {}",
repo_name,
path
);
}
return Ok(());
}
let canonical = repo_path
.canonicalize()
.with_context(|| format!("Failed to canonicalize path for '{}': {}", repo_name, path))?;
let canonical_prefix = cache_prefix
.canonicalize()
.unwrap_or_else(|_| cache_prefix.to_path_buf());
if !canonical.starts_with(&canonical_prefix) {
bail!(
"Registry path for '{}' resolves outside cache directory: {} -> {}",
repo_name,
path,
canonical.display()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_github_url_https() {
let (owner, repo) = parse_github_url("https://github.com/dojoengine/dojo").unwrap();
assert_eq!(owner, "dojoengine");
assert_eq!(repo, "dojo");
}
#[test]
fn test_parse_github_url_https_git() {
let (owner, repo) = parse_github_url("https://github.com/dojoengine/dojo.git").unwrap();
assert_eq!(owner, "dojoengine");
assert_eq!(repo, "dojo");
}
#[test]
fn test_parse_github_url_ssh() {
let (owner, repo) = parse_github_url("git@github.com:dojoengine/dojo.git").unwrap();
assert_eq!(owner, "dojoengine");
assert_eq!(repo, "dojo");
}
#[test]
fn test_parse_github_url_short() {
let (owner, repo) = parse_github_url("dojoengine/dojo").unwrap();
assert_eq!(owner, "dojoengine");
assert_eq!(repo, "dojo");
}
#[test]
fn test_registry_default() {
let registry = Registry::default();
assert_eq!(registry.version, 0);
assert!(registry.repos.is_empty());
}
#[test]
fn test_validate_repo_path_good() {
let cache = Path::new("/home/user/.patina/cache/repos");
assert!(validate_repo_path(
"/home/user/.patina/cache/repos/owner/repo",
cache,
"owner/repo"
)
.is_ok());
}
#[test]
fn test_validate_repo_path_traversal() {
let cache = Path::new("/home/user/.patina/cache/repos");
assert!(validate_repo_path(
"/home/user/.patina/cache/repos/../../etc/passwd",
cache,
"evil"
)
.is_err());
}
#[test]
fn test_validate_repo_path_outside() {
let cache = Path::new("/home/user/.patina/cache/repos");
assert!(validate_repo_path("/tmp/evil", cache, "evil").is_err());
}
}