use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::git::gh_cmd;
use crate::policy::{AuthType, RemoteConfig};
use crate::secrets::{load_secret, sync_secrets_dir};
fn strip_ansi(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
match chars.peek() {
Some('[') => {
chars.next();
while let Some(&next) = chars.peek() {
chars.next();
if ('@'..='~').contains(&next) {
break;
}
}
}
Some(']') => {
chars.next();
while let Some(next) = chars.next() {
if next == '\x07' {
break;
}
if next == '\x1b' && chars.peek() == Some(&'\\') {
chars.next();
break;
}
}
}
_ => {
chars.next();
}
}
} else {
result.push(c);
}
}
result
}
fn visibility_cache_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".local/state/dracon/visibility-sync")
}
fn visibility_cache_path(repo_path: &Path) -> PathBuf {
let path_str = repo_path.to_string_lossy();
let hash = simple_hash(&path_str);
visibility_cache_dir().join(format!("{}.last", hash))
}
fn simple_hash(s: &str) -> String {
let mut hash: u64 = 0xcbf29ce484222325; for byte in s.bytes() {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x100000001b3); }
format!("{:016x}", hash)
}
fn is_visibility_cache_fresh(repo_path: &Path, interval_hours: u64) -> bool {
let path = visibility_cache_path(repo_path);
if !path.exists() {
return false;
}
let Ok(content) = std::fs::read_to_string(&path) else {
return false;
};
let Ok(last_ts) = content.trim().parse::<u64>() else {
return false;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let interval_secs = interval_hours.saturating_mul(3600);
now.saturating_sub(last_ts) < interval_secs
}
fn update_visibility_cache(repo_path: &Path) {
let dir = visibility_cache_dir();
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!(
"⚠️ failed to create visibility cache dir {}: {}",
dir.display(),
e
);
return;
}
let path = visibility_cache_path(repo_path);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if let Err(e) = std::fs::write(&path, now.to_string()) {
eprintln!(
"⚠️ failed to write visibility cache {}: {}",
path.display(),
e
);
}
}
pub(crate) fn parse_github_owner_repo(remote_url: &str) -> Option<(String, String)> {
if remote_url.contains('@') {
if let Some(colon) = remote_url.rfind(':') {
let after_colon = &remote_url[colon + 1..];
let clean = after_colon.strip_suffix(".git").unwrap_or(after_colon);
if let Some(slash) = clean.find('/') {
return Some((clean[..slash].to_string(), clean[slash + 1..].to_string()));
}
}
}
if let Some(host_start) = remote_url.find("github.com/") {
let after_host = &remote_url[host_start + 11..];
let clean = after_host.strip_suffix(".git").unwrap_or(after_host);
if let Some(slash) = clean.find('/') {
return Some((clean[..slash].to_string(), clean[slash + 1..].to_string()));
}
}
None
}
pub(crate) fn get_github_visibility(owner: &str, repo: &str) -> bool {
let output = match gh_cmd()
.args([
"api",
&format!("repos/{}/{}", owner, repo),
"--jq",
".private",
])
.output()
{
Ok(o) => o,
Err(e) => {
eprintln!("⚠️ gh api failed (is gh installed?): {}", e);
return true;
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("⚠️ gh api failed: {}", stderr.trim());
return true;
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
stdout == "true"
}
const GITLAB_API_PROJECTS: &str = "https://gitlab.com/api/v4/projects/{}%2F{}";
const CODEBERG_API_REPOS: &str = "https://codeberg.org/api/v1/repos/{}/{}";
fn set_gitlab_visibility(owner: &str, repo: &str, token: &str, private: bool) -> Result<()> {
use std::io::Write;
let visibility = if private { "private" } else { "public" };
let encoded = format!("{}%2F{}", owner, repo);
let url = GITLAB_API_PROJECTS.replace("{}", &encoded);
let body = format!("visibility={}", visibility);
let header = format!("PRIVATE-TOKEN: {}\r\n", token);
let mut child = std::process::Command::new("curl")
.args([
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"-H",
"@-",
"-X",
"PUT",
"--data-binary",
&body,
&url,
])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.with_context(|| "curl failed to run for GitLab visibility update")?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| anyhow::anyhow!("curl stdin not available"))?;
stdin
.write_all(header.as_bytes())
.with_context(|| "failed to write GitLab PRIVATE-TOKEN header to curl stdin")?;
}
let output = child
.wait_with_output()
.with_context(|| "curl wait_with_output failed for GitLab visibility update")?;
let code = String::from_utf8_lossy(&output.stdout).trim().to_string();
match code.as_str() {
"200" => Ok(()),
"401" => Err(anyhow::anyhow!(
"GitLab visibility update failed: unauthorized (invalid token)"
)),
"404" => Err(anyhow::anyhow!(
"GitLab visibility update failed: repo not found"
)),
_ => Err(anyhow::anyhow!(
"GitLab visibility update failed: HTTP {}",
code
)),
}
}
fn set_codeberg_visibility(owner: &str, repo: &str, token: &str, private: bool) -> Result<()> {
use std::io::Write;
let url = CODEBERG_API_REPOS.replace("{}", &format!("{}/{}", owner, repo));
let json = format!("{{\"private\":{}}}", private);
let headers = format!(
"Authorization: token {}\r\nContent-Type: application/json\r\n",
token
);
let mut child = std::process::Command::new("curl")
.args([
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"-H",
"@-",
"-X",
"PATCH",
"--data-binary",
&json,
&url,
])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.with_context(|| "curl failed to run for Codeberg visibility update")?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| anyhow::anyhow!("curl stdin not available"))?;
stdin
.write_all(headers.as_bytes())
.with_context(|| "failed to write Codeberg Authorization header to curl stdin")?;
}
let output = child
.wait_with_output()
.with_context(|| "curl wait_with_output failed for Codeberg visibility update")?;
let code = String::from_utf8_lossy(&output.stdout).trim().to_string();
match code.as_str() {
"200" => Ok(()),
"401" => Err(anyhow::anyhow!(
"Codeberg visibility update failed: unauthorized (invalid token)"
)),
"404" => Err(anyhow::anyhow!(
"Codeberg visibility update failed: repo not found"
)),
_ => Err(anyhow::anyhow!(
"Codeberg visibility update failed: HTTP {}",
code
)),
}
}
pub(crate) fn sync_mirror_visibility(
origin_url: &str,
remotes: &[RemoteConfig],
repo_path: &Path,
interval_hours: u64,
) {
if is_visibility_cache_fresh(repo_path, interval_hours) {
return;
}
let repo_name = repo_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if repo_name.is_empty() {
return;
}
let Some((owner, gh_repo)) = parse_github_owner_repo(origin_url) else {
eprintln!(
"⚠️ could not parse GitHub owner/repo from origin URL: {}",
origin_url
);
return;
};
let github_private = get_github_visibility(&owner, &gh_repo);
let visibility_str = if github_private { "private" } else { "public" };
if crate::policy::debug_enabled() {
eprintln!("🐛 GitHub repo {}/{} is {}", owner, gh_repo, visibility_str);
}
for remote in remotes {
let auth = remote.effective_auth_type();
let account = remote.resolve_account();
if auth == AuthType::GitLab {
let token_var = remote
.auto_create_token_var
.as_deref()
.unwrap_or("GITLAB_TOKEN");
if let Some(token) = load_secret(token_var, &sync_secrets_dir()) {
let resolved_name = remote.resolve_repo_name(&repo_name);
if let Err(e) =
set_gitlab_visibility(&account, &resolved_name, &token, github_private)
{
eprintln!(
"⚠️ failed to set GitLab visibility for {}: {}",
resolved_name, e
);
} else if crate::policy::debug_enabled() {
eprintln!(
"🐛 set GitLab {}/{} to {}",
account, resolved_name, visibility_str
);
}
} else {
eprintln!("⚠️ no GITLAB_TOKEN for visibility sync on {}", remote.name);
}
}
if auth == AuthType::Codeberg {
let token_var = remote
.auto_create_token_var
.as_deref()
.unwrap_or("CODEBERG_TOKEN");
if let Some(token) = load_secret(token_var, &sync_secrets_dir()) {
let resolved_name = remote.resolve_repo_name(&repo_name);
if let Err(e) =
set_codeberg_visibility(&account, &resolved_name, &token, github_private)
{
eprintln!(
"⚠️ failed to set Codeberg visibility for {}: {}",
resolved_name, e
);
} else if crate::policy::debug_enabled() {
eprintln!(
"🐛 set Codeberg {}/{} to {}",
account, resolved_name, visibility_str
);
}
} else {
eprintln!(
"⚠️ no CODEBERG_TOKEN for visibility sync on {}",
remote.name
);
}
}
}
update_visibility_cache(repo_path);
}
#[derive(Debug, Clone, Default)]
pub(crate) struct RepoMetadata {
pub(crate) description: String,
pub(crate) topics: Vec<String>,
}
pub(crate) fn get_github_metadata(owner: &str, repo: &str) -> RepoMetadata {
let output = match gh_cmd()
.args([
"api",
&format!("repos/{}/{}", owner, repo),
"--jq",
"{description: .description, topics: .topics}",
])
.output()
{
Ok(o) => o,
Err(e) => {
eprintln!("⚠️ gh api metadata failed (is gh installed?): {}", e);
return RepoMetadata::default();
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("⚠️ gh api metadata failed: {}", stderr.trim());
return RepoMetadata::default();
}
let stdout_raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stdout = strip_ansi(&stdout_raw);
match serde_json::from_str::<RepoMetadataJson>(&stdout) {
Ok(m) => RepoMetadata {
description: m.description.unwrap_or_default().trim().to_string(),
topics: m.topics.unwrap_or_default(),
},
Err(e) => {
eprintln!("⚠️ failed to parse gh api metadata JSON: {}", e);
RepoMetadata::default()
}
}
}
#[derive(Deserialize)]
struct RepoMetadataJson {
description: Option<String>,
topics: Option<Vec<String>>,
}
fn set_gitlab_metadata(owner: &str, repo: &str, token: &str, meta: &RepoMetadata) -> Result<()> {
use std::io::Write;
let encoded = format!("{}%2F{}", owner, repo);
let url = GITLAB_API_PROJECTS.replace("{}", &encoded);
let mut form_data = vec![format!(
"description={}",
urlencoding::encode(&meta.description)
)];
for topic in &meta.topics {
form_data.push(format!("tag_list[]={}", urlencoding::encode(topic)));
}
let form_body = form_data.join("&");
let header = format!("PRIVATE-TOKEN: {}\r\n", token);
let mut child = std::process::Command::new("curl")
.args([
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"-H",
"@-",
"-X",
"PUT",
"--data-binary",
&form_body,
&url,
])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.with_context(|| "curl failed to run for GitLab metadata update")?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| anyhow::anyhow!("curl stdin not available"))?;
stdin
.write_all(header.as_bytes())
.with_context(|| "failed to write GitLab PRIVATE-TOKEN header to curl stdin")?;
}
let output = child
.wait_with_output()
.with_context(|| "curl wait_with_output failed for GitLab metadata update")?;
let code = String::from_utf8_lossy(&output.stdout).trim().to_string();
match code.as_str() {
"200" => Ok(()),
"401" => Err(anyhow::anyhow!(
"GitLab metadata update failed: unauthorized"
)),
"404" => Err(anyhow::anyhow!(
"GitLab metadata update failed: repo not found"
)),
_ => Err(anyhow::anyhow!(
"GitLab metadata update failed: HTTP {}",
code
)),
}
}
fn set_codeberg_metadata(owner: &str, repo: &str, token: &str, meta: &RepoMetadata) -> Result<()> {
use std::io::Write;
let url = CODEBERG_API_REPOS.replace("{}", &format!("{}/{}", owner, repo));
let json = serde_json::json!({
"description": if meta.description.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(meta.description.clone()) },
"topics": meta.topics,
});
let headers = format!(
"Authorization: token {}\r\nContent-Type: application/json\r\n",
token
);
let mut child = std::process::Command::new("curl")
.args([
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"-H",
"@-",
"-X",
"PATCH",
"--data-binary",
&json.to_string(),
&url,
])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.with_context(|| "curl failed to run for Codeberg metadata update")?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| anyhow::anyhow!("curl stdin not available"))?;
stdin
.write_all(headers.as_bytes())
.with_context(|| "failed to write Codeberg Authorization header to curl stdin")?;
}
let output = child
.wait_with_output()
.with_context(|| "curl wait_with_output failed for Codeberg metadata update")?;
let code = String::from_utf8_lossy(&output.stdout).trim().to_string();
match code.as_str() {
"200" => Ok(()),
"401" => Err(anyhow::anyhow!(
"Codeberg metadata update failed: unauthorized"
)),
"404" => Err(anyhow::anyhow!(
"Codeberg metadata update failed: repo not found"
)),
_ => Err(anyhow::anyhow!(
"Codeberg metadata update failed: HTTP {}",
code
)),
}
}
pub(crate) fn sync_mirror_metadata(
origin_url: &str,
remotes: &[RemoteConfig],
repo_path: &Path,
interval_hours: u64,
) {
if is_visibility_cache_fresh(repo_path, interval_hours) {
return;
}
let repo_name = repo_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if repo_name.is_empty() {
return;
}
let Some((owner, gh_repo)) = parse_github_owner_repo(origin_url) else {
return;
};
let meta = get_github_metadata(&owner, &gh_repo);
if crate::policy::debug_enabled() {
eprintln!(
"🐛 GitHub {}/{} metadata: description={:?} topics={:?}",
owner, gh_repo, meta.description, meta.topics
);
}
for remote in remotes {
let auth = remote.effective_auth_type();
let account = remote.resolve_account();
if auth == AuthType::GitLab {
let token_var = remote
.auto_create_token_var
.as_deref()
.unwrap_or("GITLAB_TOKEN");
if let Some(token) = load_secret(token_var, &sync_secrets_dir()) {
let resolved_name = remote.resolve_repo_name(&repo_name);
if let Err(e) = set_gitlab_metadata(&account, &resolved_name, &token, &meta) {
eprintln!(
"⚠️ failed to set GitLab metadata for {}: {}",
resolved_name, e
);
} else if crate::policy::debug_enabled() {
eprintln!("🐛 set GitLab {}/{} metadata", account, resolved_name);
}
}
}
if auth == AuthType::Codeberg {
let token_var = remote
.auto_create_token_var
.as_deref()
.unwrap_or("CODEBERG_TOKEN");
if let Some(token) = load_secret(token_var, &sync_secrets_dir()) {
let resolved_name = remote.resolve_repo_name(&repo_name);
if let Err(e) = set_codeberg_metadata(&account, &resolved_name, &token, &meta) {
eprintln!(
"⚠️ failed to set Codeberg metadata for {}: {}",
resolved_name, e
);
} else if crate::policy::debug_enabled() {
eprintln!("🐛 set Codeberg {}/{} metadata", account, resolved_name);
}
}
}
}
}
pub(crate) fn prune_stale_visibility_cache(
repo_set: &std::collections::BTreeSet<std::path::PathBuf>,
) -> Result<()> {
let cache_dir = visibility_cache_dir();
if !cache_dir.exists() {
return Ok(());
}
let valid_hashes: std::collections::HashSet<String> = repo_set
.iter()
.map(|r| format!("{}.last", simple_hash(&r.to_string_lossy())))
.collect();
let mut removed = 0;
for entry in std::fs::read_dir(&cache_dir)? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.ends_with(".last") && !valid_hashes.contains(name_str.as_ref()) {
std::fs::remove_file(entry.path())?;
removed += 1;
}
}
if removed > 0 {
eprintln!(
"🧹 startup: pruned {} stale visibility cache entries",
removed
);
}
Ok(())
}
#[cfg(test)]
pub(crate) fn github_visibility_at_creation(
owner: &str,
repo_name: &str,
sync_visibility: bool,
) -> bool {
if !sync_visibility {
return true;
}
get_github_visibility(owner, repo_name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_github_owner_repo_ssh() {
let result = parse_github_owner_repo("git@github.com:DraconDev/my-repo.git");
assert_eq!(
result,
Some(("DraconDev".to_string(), "my-repo".to_string()))
);
}
#[test]
fn test_parse_github_owner_repo_https() {
let result = parse_github_owner_repo("https://github.com/DraconDev/my-repo.git");
assert_eq!(
result,
Some(("DraconDev".to_string(), "my-repo".to_string()))
);
}
#[test]
fn test_parse_github_owner_repo_no_git_suffix() {
let result = parse_github_owner_repo("git@github.com:DraconDev/my-repo");
assert_eq!(
result,
Some(("DraconDev".to_string(), "my-repo".to_string()))
);
}
#[test]
fn test_parse_github_owner_repo_invalid_url() {
let result = parse_github_owner_repo("not-a-url");
assert_eq!(result, None);
}
#[test]
fn test_parse_github_owner_repo_gitlab_url() {
let result = parse_github_owner_repo("git@gitlab.com:someone/repo.git");
assert_eq!(result, Some(("someone".to_string(), "repo".to_string())));
}
#[test]
fn test_visibility_cache_not_fresh_when_missing() {
let repo_path = Path::new("/tmp/test_repo_that_should_not_exist_12345");
assert!(!is_visibility_cache_fresh(repo_path, 24));
}
#[test]
fn test_visibility_cache_fresh_when_recent() {
let repo_path = Path::new("/tmp/test_cache_fresh");
update_visibility_cache(repo_path);
assert!(is_visibility_cache_fresh(repo_path, 24));
let _ = std::fs::remove_file(visibility_cache_path(repo_path));
}
#[test]
fn test_visibility_cache_stale_when_old() {
let repo_path = Path::new("/tmp/test_cache_stale");
let path = visibility_cache_path(repo_path);
let old_ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(25 * 3600);
std::fs::create_dir_all(visibility_cache_dir()).unwrap();
std::fs::write(&path, old_ts.to_string()).unwrap();
assert!(!is_visibility_cache_fresh(repo_path, 24));
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_visibility_cache_updates_timestamp() {
let repo_path = Path::new("/tmp/test_cache_update");
let path = visibility_cache_path(repo_path);
let old_ts = "1000";
std::fs::create_dir_all(visibility_cache_dir()).unwrap();
std::fs::write(&path, old_ts).unwrap();
update_visibility_cache(repo_path);
let new_content = std::fs::read_to_string(&path).unwrap();
let new_ts = new_content.trim().parse::<u64>().unwrap();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(new_ts > 1000);
assert!(new_ts <= now);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_github_visibility_at_creation_disabled() {
assert!(github_visibility_at_creation("DraconDev", "test", false));
}
#[test]
fn test_get_github_visibility_returns_safe_default_on_error() {
let result = get_github_visibility("nonexistent-owner-12345", "nonexistent-repo-67890");
assert!(result, "safe default should be private");
}
#[test]
fn test_sync_mirror_visibility_skips_when_cache_fresh() {
let repo_path = Path::new("/tmp/test_skip_cached");
update_visibility_cache(repo_path);
let remotes = vec![RemoteConfig {
name: "gitlab".to_string(),
push_url: "git@gitlab.com:test/repo.git".to_string(),
auto_create: false,
auto_create_account: "test".to_string(),
auth_type: AuthType::GitLab,
priority: 50,
api_endpoint: None,
auto_create_token_var: None,
repo_name_map: Default::default(),
force_push_when_behind: false,
}];
sync_mirror_visibility("git@github.com:DraconDev/test.git", &remotes, repo_path, 24);
let _ = std::fs::remove_file(visibility_cache_path(repo_path));
}
#[test]
fn test_sync_mirror_visibility_handles_unparseable_origin() {
let repo_path = Path::new("/tmp/test_bad_origin");
let remotes: Vec<RemoteConfig> = vec![];
sync_mirror_visibility("not-a-valid-url", &remotes, repo_path, 0);
let _ = std::fs::remove_file(visibility_cache_path(repo_path));
}
#[test]
fn test_parse_github_owner_repo_with_dots() {
let result = parse_github_owner_repo("git@github.com:DraconDev/.dracon.git");
assert_eq!(
result,
Some(("DraconDev".to_string(), ".dracon".to_string()))
);
}
#[test]
fn test_parse_github_owner_repo_with_name_mapping() {
let result = parse_github_owner_repo("https://github.com/my-org/some-repo.git");
assert_eq!(
result,
Some(("my-org".to_string(), "some-repo".to_string()))
);
}
#[test]
fn test_github_visibility_at_creation_enabled_private() {
assert!(github_visibility_at_creation("DraconDev", "test", true));
}
#[test]
fn test_sync_visibility_defaults_in_policy() {
let toml = "";
let policy: crate::policy::SyncPolicy = toml::from_str(toml).unwrap_or_else(|_| {
toml::from_str("pulse_interval_secs = 1").unwrap()
});
assert!(
!policy.sync_visibility,
"sync_visibility should default to false"
);
assert_eq!(
policy.sync_visibility_interval_hours, 24,
"interval should default to 24"
);
}
#[test]
fn test_sync_visibility_parses_true() {
let toml = "sync_visibility = true\nsync_visibility_interval_hours = 6";
let policy: crate::policy::SyncPolicy = toml::from_str(toml)
.unwrap_or_else(|_| toml::from_str("pulse_interval_secs = 1\nsync_visibility = true\nsync_visibility_interval_hours = 6").unwrap());
assert!(policy.sync_visibility);
assert_eq!(policy.sync_visibility_interval_hours, 6);
}
#[test]
fn test_set_gitlab_visibility_builds_correct_request() {
let result = set_gitlab_visibility("testowner", "testrepo", "faketoken", true);
let _ = result;
}
#[test]
fn test_set_codeberg_visibility_builds_correct_request() {
let result = set_codeberg_visibility("testowner", "testrepo", "faketoken", false);
let _ = result;
}
#[test]
fn test_sync_mirror_visibility_does_not_panic_on_all_failures() {
let _repo_name = "test_all_failures";
let remotes = vec![
RemoteConfig {
name: "gitlab".to_string(),
push_url: "git@gitlab.com:test/repo.git".to_string(),
auto_create: false,
auto_create_account: "test".to_string(),
auth_type: AuthType::GitLab,
priority: 50,
api_endpoint: None,
auto_create_token_var: Some("NONEXISTENT_TOKEN_VAR_12345".to_string()),
repo_name_map: Default::default(),
force_push_when_behind: false,
},
RemoteConfig {
name: "codeberg".to_string(),
push_url: "git@codeberg.org:test/repo.git".to_string(),
auto_create: false,
auto_create_account: "test".to_string(),
auth_type: AuthType::Codeberg,
priority: 50,
api_endpoint: None,
auto_create_token_var: Some("NONEXISTENT_TOKEN_VAR_12345".to_string()),
repo_name_map: Default::default(),
force_push_when_behind: false,
},
];
sync_mirror_visibility(
"git@github.com:DraconDev/test.git",
&remotes,
Path::new("/tmp/test_all_failures"),
0,
);
let _ = std::fs::remove_file(visibility_cache_path(Path::new("/tmp/test_all_failures")));
}
#[test]
fn test_cache_prevents_repeated_api_calls() {
let repo_path = Path::new("/tmp/test_idempotency_cache");
update_visibility_cache(repo_path);
assert!(is_visibility_cache_fresh(repo_path, 24));
let _ = std::fs::remove_file(visibility_cache_path(repo_path));
}
#[test]
fn test_cache_written_on_parseable_origin_even_when_tokens_missing() {
let repo_path = Path::new("/tmp/test_cache_on_failure");
let remotes: Vec<RemoteConfig> = vec![];
sync_mirror_visibility("git@github.com:DraconDev/test.git", &remotes, repo_path, 0);
let path = visibility_cache_path(repo_path);
assert!(
path.exists(),
"cache should be written even when no remotes configured"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_cache_not_written_on_unparseable_origin() {
let repo_path = Path::new("/tmp/test_cache_unparseable");
let remotes: Vec<RemoteConfig> = vec![];
sync_mirror_visibility("not-a-url", &remotes, repo_path, 0);
let path = visibility_cache_path(repo_path);
assert!(
!path.exists(),
"cache should NOT be written for unparseable URLs (need to retry)"
);
}
#[test]
fn test_parse_github_owner_repo_with_hyphens() {
let result = parse_github_owner_repo("git@github.com:my-org/my-super-repo.git");
assert_eq!(
result,
Some(("my-org".to_string(), "my-super-repo".to_string()))
);
}
#[test]
fn test_parse_github_owner_repo_empty_string() {
let result = parse_github_owner_repo("");
assert_eq!(result, None);
}
#[test]
fn test_visibility_cache_corrupt_file() {
let repo_path = Path::new("/tmp/test_corrupt_cache");
let path = visibility_cache_path(repo_path);
std::fs::create_dir_all(visibility_cache_dir()).unwrap();
std::fs::write(&path, "not-a-number").unwrap();
assert!(
!is_visibility_cache_fresh(repo_path, 24),
"corrupt cache should be treated as stale"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_get_github_metadata_returns_empty_on_error() {
let meta = get_github_metadata("nonexistent-owner-12345", "nonexistent-repo-67890");
assert!(
meta.description.is_empty(),
"description should be empty on error"
);
assert!(meta.topics.is_empty(), "topics should be empty on error");
}
#[test]
fn test_sync_metadata_defaults_in_policy() {
let toml = "";
let policy: crate::policy::SyncPolicy = toml::from_str(toml)
.unwrap_or_else(|_| toml::from_str("pulse_interval_secs = 1").unwrap());
assert!(
!policy.sync_metadata,
"sync_metadata should default to false"
);
}
#[test]
fn test_sync_metadata_parses_true() {
let toml = "sync_metadata = true";
let policy: crate::policy::SyncPolicy = toml::from_str(toml).unwrap_or_else(|_| {
toml::from_str("pulse_interval_secs = 1\nsync_metadata = true").unwrap()
});
assert!(policy.sync_metadata);
}
#[test]
fn test_sync_mirror_metadata_does_not_panic() {
let repo_path = Path::new("/tmp/test_metadata_no_panic");
let remotes: Vec<RemoteConfig> = vec![];
sync_mirror_metadata("git@github.com:DraconDev/test.git", &remotes, repo_path, 0);
let _ = std::fs::remove_file(visibility_cache_path(repo_path));
}
#[test]
fn test_sync_mirror_metadata_handles_unparseable_origin() {
let repo_path = Path::new("/tmp/test_metadata_bad_origin");
let remotes: Vec<RemoteConfig> = vec![];
sync_mirror_metadata("not-a-url", &remotes, repo_path, 0);
let _ = std::fs::remove_file(visibility_cache_path(repo_path));
}
#[test]
fn test_repo_metadata_default() {
let meta = RepoMetadata::default();
assert!(meta.description.is_empty());
assert!(meta.topics.is_empty());
}
#[test]
fn test_set_gitlab_metadata_does_not_panic() {
let meta = RepoMetadata {
description: "Test repo".to_string(),
topics: vec!["rust".to_string(), "cli".to_string()],
};
let result = set_gitlab_metadata("testowner", "testrepo", "faketoken", &meta);
let _ = result;
}
#[test]
fn test_set_codeberg_metadata_does_not_panic() {
let meta = RepoMetadata {
description: "Test repo".to_string(),
topics: vec!["rust".to_string()],
};
let result = set_codeberg_metadata("testowner", "testrepo", "faketoken", &meta);
let _ = result;
}
#[test]
fn test_sync_metadata_with_missing_tokens() {
let _repo_name = "test_metadata_missing_tokens";
let remotes = vec![
RemoteConfig {
name: "gitlab".to_string(),
push_url: "git@gitlab.com:test/repo.git".to_string(),
auto_create: false,
auto_create_account: "test".to_string(),
auth_type: AuthType::GitLab,
priority: 50,
api_endpoint: None,
auto_create_token_var: Some("NONEXISTENT_TOKEN_VAR_META".to_string()),
repo_name_map: Default::default(),
force_push_when_behind: false,
},
RemoteConfig {
name: "codeberg".to_string(),
push_url: "git@codeberg.org:test/repo.git".to_string(),
auto_create: false,
auto_create_account: "test".to_string(),
auth_type: AuthType::Codeberg,
priority: 50,
api_endpoint: None,
auto_create_token_var: Some("NONEXISTENT_TOKEN_VAR_META2".to_string()),
repo_name_map: Default::default(),
force_push_when_behind: false,
},
];
sync_mirror_metadata(
"git@github.com:DraconDev/test.git",
&remotes,
Path::new("/tmp/test_metadata_missing_tokens"),
0,
);
let _ = std::fs::remove_file(visibility_cache_path(Path::new(
"/tmp/test_metadata_missing_tokens",
)));
}
#[test]
fn test_strip_ansi_removes_color_codes() {
let input = "\x1b[1;38m{\x1b[m\n\x1b[1;34m\"description\"\x1b[m\x1b[1;38m:\x1b[m \x1b[32m\"Hello world\"\x1b[m,\n\x1b[1;34m\"topics\"\x1b[m\x1b[1;38m:\x1b[m\x1b[32m[\"rust\"]\x1b[m\n}";
let stripped = strip_ansi(input);
assert!(
!stripped.contains('\x1b'),
"ANSI codes should be removed: got {:?}",
stripped
);
let parsed: serde_json::Value =
serde_json::from_str(&stripped).expect("should be valid JSON after stripping");
assert_eq!(parsed["description"], "Hello world");
assert_eq!(parsed["topics"][0], "rust");
}
#[test]
fn test_strip_ansi_passthrough_plain_text() {
let input = r#"{"description":"plain","topics":[]}"#;
assert_eq!(strip_ansi(input), input);
}
#[test]
fn test_resolve_account_from_ssh_url() {
let remote = RemoteConfig {
name: "gitlab".to_string(),
push_url: "git@gitlab.com:dracondev/{repo}.git".to_string(),
auto_create: false,
auto_create_account: String::new(),
auth_type: AuthType::GitLab,
priority: 50,
api_endpoint: None,
auto_create_token_var: None,
repo_name_map: Default::default(),
force_push_when_behind: false,
};
assert_eq!(remote.resolve_account(), "dracondev");
}
#[test]
fn test_resolve_account_from_https_url() {
let remote = RemoteConfig {
name: "codeberg".to_string(),
push_url: "https://codeberg.org/myorg/{repo}.git".to_string(),
auto_create: false,
auto_create_account: String::new(),
auth_type: AuthType::Codeberg,
priority: 50,
api_endpoint: None,
auto_create_token_var: None,
repo_name_map: Default::default(),
force_push_when_behind: false,
};
assert_eq!(remote.resolve_account(), "myorg");
}
#[test]
fn test_resolve_account_explicit_overrides() {
let remote = RemoteConfig {
name: "github".to_string(),
push_url: "git@github.com:DraconDev/{repo}.git".to_string(),
auto_create: false,
auto_create_account: "ExplicitAccount".to_string(),
auth_type: AuthType::GitHub,
priority: 50,
api_endpoint: None,
auto_create_token_var: None,
repo_name_map: Default::default(),
force_push_when_behind: false,
};
assert_eq!(remote.resolve_account(), "ExplicitAccount");
}
}