use std::{
collections::{HashMap, HashSet},
fs::File,
io::BufReader,
str::FromStr,
sync::Arc,
};
use anyhow::{Context, Result, bail};
use console::Style;
use nostr::{
FromBech32, PublicKey, Tag, TagStandard, ToBech32,
nips::{nip01::Coordinate, nip19::Nip19Coordinate},
};
use nostr_sdk::{Kind, NostrSigner, RelayUrl, Timestamp, Url};
use serde::{Deserialize, Serialize};
use urlencoding::encode as pct_encode;
#[cfg(not(test))]
use crate::client::Client;
use crate::{
UrlWithoutSlash,
cli_interactor::{
Interactor, InteractorPrompt, PromptChoiceParms, PromptConfirmParms, PromptInputParms,
},
client::{Connect, consolidate_fetch_reports, get_repo_ref_from_cache, sign_event},
git::{
Repo, RepoActions,
nostr_url::{NostrUrlDecoded, use_nip05_git_config_cache_to_find_nip05_from_public_key},
},
login::user::get_user_details,
};
#[derive(Clone)]
pub struct RepoRef {
pub name: String,
pub description: String,
pub identifier: String,
pub root_commit: String,
pub git_server: Vec<String>,
pub web: Vec<String>,
pub relays: Vec<RelayUrl>,
pub blossoms: Vec<Url>,
pub hashtags: Vec<String>,
pub maintainers: Vec<PublicKey>,
pub trusted_maintainer: PublicKey,
pub maintainers_without_annoucnement: Option<Vec<PublicKey>>,
pub events: HashMap<Nip19Coordinate, nostr::Event>,
pub nostr_git_url: Option<NostrUrlDecoded>,
}
impl TryFrom<(nostr::Event, Option<PublicKey>)> for RepoRef {
type Error = anyhow::Error;
fn try_from((event, trusted_maintainer): (nostr::Event, Option<PublicKey>)) -> Result<Self> {
if !event.kind.eq(&Kind::GitRepoAnnouncement) {
bail!("incorrect kind");
}
let mut r = Self {
name: String::new(),
description: String::new(),
identifier: String::new(),
root_commit: String::new(),
git_server: Vec::new(),
web: Vec::new(),
relays: Vec::new(),
blossoms: Vec::new(),
hashtags: Vec::new(),
maintainers: Vec::new(),
trusted_maintainer: trusted_maintainer.unwrap_or(event.pubkey),
maintainers_without_annoucnement: None,
events: HashMap::new(),
nostr_git_url: None,
};
for tag in event.tags.iter() {
match tag.as_slice() {
[t, id, ..] if t == "d" => r.identifier = id.clone(),
[t, name, ..] if t == "name" => r.name = name.clone(),
[t, description, ..] if t == "description" => r.description = description.clone(),
[t, clone @ ..] if t == "clone" => {
for git_server in clone {
if !r.git_server.contains(git_server) {
r.git_server.push(git_server.clone());
}
}
r.git_server = clone.to_vec();
}
[t, web @ ..] if t == "web" => {
r.web = web.to_vec();
}
[t, commit_id]
if t == "r"
&& commit_id.len() == 40
&& git2::Oid::from_str(commit_id).is_ok() =>
{
r.root_commit = commit_id.clone();
}
[t, commit_id, marker]
if t == "r"
&& marker == "euc"
&& commit_id.len() == 40
&& git2::Oid::from_str(commit_id).is_ok() =>
{
r.root_commit = commit_id.clone();
}
[t, relays @ ..] if t == "relays" => {
for relay in relays {
if let Ok(relay_url) = RelayUrl::parse(relay) {
if !r.relays.contains(&relay_url) {
r.relays.push(relay_url);
}
}
}
}
[t, hashtag, ..] if t == "t" => r.hashtags.push(hashtag.clone()),
[t, blossoms @ ..] if t == "blossoms" => {
for b in blossoms {
if let Ok(b) = Url::parse(b) {
if !r.blossoms.contains(&b) {
r.blossoms.push(b);
}
}
}
}
[t, maintainers @ ..] if t == "maintainers" => {
if !maintainers.contains(&event.pubkey.to_string()) {
r.maintainers.push(event.pubkey);
}
for pk in maintainers {
r.maintainers.push(
nostr_sdk::prelude::PublicKey::from_str(pk)
.context(format!("failed to convert entry from maintainers tag {pk} into a valid nostr public key. it should be in hex format"))
.context("invalid repository event")?,
);
}
}
_ => {}
}
}
if r.maintainers.is_empty() {
r.maintainers.push(event.pubkey);
}
r.events = HashMap::new();
r.events.insert(
Nip19Coordinate {
coordinate: Coordinate {
kind: event.kind,
identifier: event.tags.identifier().unwrap().to_string(),
public_key: event.pubkey,
},
relays: vec![],
},
event,
);
Ok(r)
}
}
impl RepoRef {
pub async fn to_event(&self, signer: &Arc<dyn NostrSigner>) -> Result<nostr::Event> {
sign_event(
nostr_sdk::EventBuilder::new(nostr::event::Kind::GitRepoAnnouncement, "").tags(
[
vec![
Tag::identifier(if self.identifier.to_string().is_empty() {
self.root_commit.to_string()[..7].to_string()
} else {
self.identifier.to_string()
}),
Tag::custom(
nostr::TagKind::Custom(std::borrow::Cow::Borrowed("r")),
vec![self.root_commit.to_string(), "euc".to_string()],
),
Tag::from_standardized(TagStandard::Name(self.name.clone())),
Tag::from_standardized(TagStandard::Description(self.description.clone())),
Tag::custom(
nostr::TagKind::Custom(std::borrow::Cow::Borrowed("clone")),
self.git_server.clone(),
),
Tag::custom(
nostr::TagKind::Custom(std::borrow::Cow::Borrowed("web")),
self.web.clone(),
),
Tag::custom(
nostr::TagKind::Custom(std::borrow::Cow::Borrowed("relays")),
self.relays.iter().map(|r| r.to_string()),
),
Tag::custom(
nostr::TagKind::Custom(std::borrow::Cow::Borrowed("maintainers")),
self.maintainers
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<String>>(),
),
Tag::custom(
nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
vec![format!("git repository: {}", self.name.clone())],
),
],
self.hashtags
.iter()
.map(|h| {
Tag::custom(
nostr::TagKind::Custom(std::borrow::Cow::Borrowed("t")),
vec![h.clone()],
)
})
.collect(),
if self.blossoms.is_empty() {
vec![]
} else {
vec![Tag::custom(
nostr::TagKind::Custom(std::borrow::Cow::Borrowed("blossoms")),
self.blossoms
.iter()
.map(|r| r.to_string_without_trailing_slash()),
)]
},
]
.concat(),
),
signer,
"repo announcement".to_string(),
)
.await
.context("failed to create repository reference event")
}
pub fn coordinates(&self) -> HashSet<Nip19Coordinate> {
let mut res = HashSet::new();
res.insert(Nip19Coordinate {
coordinate: Coordinate {
kind: Kind::GitRepoAnnouncement,
public_key: self.trusted_maintainer,
identifier: self.identifier.clone(),
},
relays: vec![],
});
for m in &self.maintainers {
res.insert(Nip19Coordinate {
coordinate: Coordinate {
kind: Kind::GitRepoAnnouncement,
public_key: *m,
identifier: self.identifier.clone(),
},
relays: vec![],
});
}
res
}
pub fn coordinate_with_hint(&self) -> Nip19Coordinate {
Nip19Coordinate {
coordinate: Coordinate {
kind: Kind::GitRepoAnnouncement,
public_key: self.trusted_maintainer,
identifier: self.identifier.clone(),
},
relays: if let Some(relay) = self.relays.first() {
vec![relay.clone()]
} else {
vec![]
},
}
}
pub fn coordinates_with_timestamps(&self) -> Vec<(Nip19Coordinate, Option<Timestamp>)> {
self.coordinates()
.iter()
.map(|c| (c.clone(), self.events.get(c).map(|e| e.created_at)))
.collect::<Vec<(Nip19Coordinate, Option<Timestamp>)>>()
}
pub fn set_nostr_git_url(&mut self, nostr_git_url: NostrUrlDecoded) {
self.nostr_git_url = Some(nostr_git_url)
}
pub fn to_nostr_git_url(&self, git_repo: &Option<&Repo>) -> NostrUrlDecoded {
if let Some(nostr_git_url) = &self.nostr_git_url {
return nostr_git_url.clone();
}
let c = self.coordinate_with_hint();
NostrUrlDecoded {
original_string: String::new(),
nip05: use_nip05_git_config_cache_to_find_nip05_from_public_key(
&c.public_key,
git_repo,
)
.unwrap_or_default(),
coordinate: c,
protocol: None,
ssh_key_file: None,
}
}
pub fn grasp_servers(&self) -> Vec<String> {
detect_existing_grasp_servers(Some(self), &[], &[], &self.identifier)
}
pub fn add_grasp_server(&mut self, clone_url: &str) -> Result<bool> {
if !is_grasp_server_clone_url(clone_url) {
bail!("invalid grasp server clone url. does not end with .git");
}
let relay_url = RelayUrl::parse(
&format_grasp_server_url_as_relay_url(clone_url)
.context("invalid grasp server clone url")?,
)
.context("invalid grasp server clone url")?;
if !self.relays.contains(&relay_url) {
self.relays.push(relay_url);
}
if !self.git_server.contains(&clone_url.to_string()) {
self.git_server.push(clone_url.to_string());
Ok(true)
} else {
Ok(false)
}
}
}
pub async fn get_repo_coordinates_when_remote_unknown(
git_repo: &Repo,
#[cfg(test)] client: &crate::client::MockConnect,
#[cfg(not(test))] client: &Client,
) -> Result<Nip19Coordinate> {
if let Ok(c) = try_and_get_repo_coordinates_when_remote_unknown(git_repo).await {
Ok(c)
} else {
get_repo_coordinate_from_user_prompt(git_repo, client).await
}
}
pub async fn try_and_get_repo_coordinates_when_remote_unknown(
git_repo: &Repo,
) -> Result<Nip19Coordinate> {
let remote_coordinates = get_repo_coordinates_from_nostr_remotes(git_repo).await?;
if remote_coordinates.is_empty() {
if let Ok(c) = get_repo_coordinates_from_git_config(git_repo) {
Ok(c)
} else {
get_repo_coordinates_from_maintainers_yaml(git_repo)
.await
.context("no nostr git remotes or git config \"nostr.repo\" value")
}
} else if remote_coordinates.len() == 1
|| remote_coordinates.values().all(|coordinate| {
let first = remote_coordinates.values().next().unwrap();
coordinate.public_key == first.public_key && coordinate.identifier == first.identifier
})
{
Ok(remote_coordinates.values().next().unwrap().clone())
} else {
let choice_index = Interactor::default().choice(
PromptChoiceParms::default()
.with_prompt("select nostr repository from those listed as git remotes")
.with_default(0)
.with_choices(
get_nostr_git_remote_selection_labels(git_repo, &remote_coordinates).await?,
),
)?;
Ok(remote_coordinates
.get(
remote_coordinates
.keys()
.cloned()
.collect::<Vec<String>>()
.get(choice_index)
.unwrap(),
)
.unwrap()
.clone())
}
}
async fn get_nostr_git_remote_selection_labels(
git_repo: &Repo,
remote_coordinates: &HashMap<String, Nip19Coordinate>,
) -> Result<Vec<String>> {
let mut res = vec![];
for (remote, c) in remote_coordinates {
res.push(format!(
"{remote} - {}/{}",
get_user_details(&c.public_key, None, Some(git_repo.get_path()?), true, false)
.await?
.metadata
.name,
c.identifier
));
}
Ok(res)
}
fn get_repo_coordinates_from_git_config(git_repo: &Repo) -> Result<Nip19Coordinate> {
Nip19Coordinate::from_bech32(
&git_repo
.get_git_config_item("nostr.repo", Some(false))?
.context("git config item \"nostr.repo\" is not set in local repository")?,
)
.context("git config item \"nostr.repo\" is not an naddr")
}
async fn get_repo_coordinates_from_nostr_remotes(
git_repo: &Repo,
) -> Result<HashMap<String, Nip19Coordinate>> {
let mut repo_coordinates = HashMap::new();
for remote_name in git_repo.git_repo.remotes()?.iter().flatten() {
if let Some(remote_url) = git_repo.git_repo.find_remote(remote_name)?.url() {
if let Ok(nostr_url_decoded) =
NostrUrlDecoded::parse_and_resolve(remote_url, &Some(git_repo)).await
{
repo_coordinates.insert(remote_name.to_string(), nostr_url_decoded.coordinate);
}
}
}
Ok(repo_coordinates)
}
async fn get_repo_coordinates_from_maintainers_yaml(git_repo: &Repo) -> Result<Nip19Coordinate> {
let repo_config = get_repo_config_from_yaml(git_repo)?;
Ok(Nip19Coordinate {
coordinate: Coordinate {
identifier: repo_config
.identifier
.context("maintainers.yaml doesnt list the identifier")?,
kind: Kind::GitRepoAnnouncement,
public_key: PublicKey::from_bech32(
repo_config
.maintainers
.first()
.context("maintainers.yaml doesnt list any maintainers")?,
)
.context("maintainers.yaml doesn't list the first maintainer using a valid npub")?,
},
relays: repo_config
.relays
.iter()
.filter_map(|url| RelayUrl::parse(url).ok())
.collect(),
})
}
async fn get_repo_coordinate_from_user_prompt(
git_repo: &Repo,
#[cfg(test)] client: &crate::client::MockConnect,
#[cfg(not(test))] client: &Client,
) -> Result<Nip19Coordinate> {
let dim = Style::new().color256(247);
println!(
"{}",
dim.apply_to(
"hint: https://gitworkshop.dev/search lists repositories and their nostr address"
),
);
let git_repo_path = git_repo.get_path()?;
let coordinate = {
loop {
let input = Interactor::default()
.input(PromptInputParms::default().with_prompt("nostr repository"))?;
let coordinate = if let Ok(c) = Nip19Coordinate::from_bech32(&input) {
c
} else if let Ok(nostr_url) =
NostrUrlDecoded::parse_and_resolve(&input, &Some(git_repo)).await
{
nostr_url.coordinate
} else {
eprintln!("not a valid naddr or git nostr remote URL starting nostr://");
continue;
};
let term = console::Term::stderr();
term.write_line("searching for repository...")?;
let (relay_reports, progress_reporter) = client
.fetch_all(
Some(git_repo_path),
Some(&coordinate),
&HashSet::from_iter(vec![coordinate.public_key]),
)
.await?;
let relay_errs = relay_reports.iter().any(std::result::Result::is_err);
let report = consolidate_fetch_reports(relay_reports);
if !relay_errs && !report.to_string().is_empty() {
let _ = progress_reporter.clear();
}
if report.to_string().is_empty() {
eprintln!("couldn't find repository");
continue;
} else {
eprintln!("repository found");
break coordinate;
}
}
};
let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &coordinate).await?;
if Interactor::default().confirm(
PromptConfirmParms::default()
.with_default(true)
.with_prompt("set git remote \"origin\" to nostr repository url?"),
)? {
set_or_create_git_remote_with_nostr_url("origin", &repo_ref, git_repo)?;
} else if Interactor::default().confirm(
PromptConfirmParms::default()
.with_default(true)
.with_prompt("set up new git remote for the nostr repository?"),
)? {
let name =
Interactor::default().input(PromptInputParms::default().with_prompt("remote name"))?;
set_or_create_git_remote_with_nostr_url(&name, &repo_ref, git_repo)?;
}
git_repo.save_git_config_item("nostr.repo", &coordinate.to_bech32()?, false)?;
Ok(coordinate)
}
fn set_or_create_git_remote_with_nostr_url(
name: &str,
repo_ref: &RepoRef,
git_repo: &Repo,
) -> Result<()> {
let url = repo_ref.to_nostr_git_url(&Some(git_repo)).to_string();
if git_repo.git_repo.remote_set_url(name, &url).is_err() {
git_repo.git_repo.remote(name, &url)?;
}
eprintln!("set git remote \"{name}\" to {url}");
Ok(())
}
#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)]
pub struct RepoConfigYaml {
pub identifier: Option<String>,
pub maintainers: Vec<String>,
pub relays: Vec<String>,
}
pub fn get_repo_config_from_yaml(git_repo: &Repo) -> Result<RepoConfigYaml> {
let path = git_repo.get_path()?.join("maintainers.yaml");
let file = File::open(path)
.context("should open maintainers.yaml if it exists")
.context("maintainers.yaml doesnt exist")?;
let reader = BufReader::new(file);
let repo_config_yaml: RepoConfigYaml = serde_yaml::from_reader(reader)
.context("should read maintainers.yaml with serde_yaml")
.context("maintainers.yaml incorrectly formatted")?;
Ok(repo_config_yaml)
}
pub fn extract_pks(pk_strings: Vec<String>) -> Result<Vec<PublicKey>> {
let mut pks: Vec<PublicKey> = vec![];
for s in pk_strings {
pks.push(
nostr_sdk::prelude::PublicKey::from_bech32(&s).context(format!(
"failed to convert {s} into a valid nostr public key"
))?,
);
}
Ok(pks)
}
pub fn save_repo_config_to_yaml(
git_repo: &Repo,
identifier: String,
maintainers: Vec<PublicKey>,
relays: Vec<String>,
) -> Result<()> {
let path = git_repo.get_path()?.join("maintainers.yaml");
let file = if path.exists() {
std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)
.context("failed to open maintainers.yaml file with write and truncate options")?
} else {
std::fs::File::create(path).context("failed to create maintainers.yaml file")?
};
let mut maintainers_npubs = vec![];
for m in maintainers {
maintainers_npubs.push(
m.to_bech32()
.context("failed to convert public key into npub")?,
);
}
serde_yaml::to_writer(
file,
&RepoConfigYaml {
identifier: Some(identifier),
maintainers: maintainers_npubs,
relays,
},
)
.context("failed to write maintainers to maintainers.yaml file serde_yaml")
}
pub fn detect_existing_grasp_servers(
repo_ref: Option<&RepoRef>,
args_relays: &[String],
args_clone_url: &[String],
identifier: &str,
) -> Vec<String> {
let clone_urls: Vec<String> = if !args_clone_url.is_empty() {
args_clone_url.to_vec()
} else if let Some(repo) = repo_ref {
repo.git_server.clone()
} else {
Vec::new()
};
let relays: Vec<RelayUrl> = if !args_relays.is_empty() {
args_relays
.iter()
.filter_map(|r| RelayUrl::parse(r).ok())
.collect()
} else if let Some(repo) = repo_ref {
repo.relays.clone()
} else {
Vec::new()
};
let mut existing_grasp_servers = Vec::new();
for url in &clone_urls {
let Ok(formatted_as_grasp_server_url) = normalize_grasp_server_url(url) else {
continue;
};
if existing_grasp_servers.contains(&formatted_as_grasp_server_url) {
continue;
}
let clone_url_is_grasp_server_format = if let Ok(npub) = extract_npub(url) {
url.contains(&format!("/{npub}/{}.git", pct_encode(identifier)))
} else {
false
};
if !clone_url_is_grasp_server_format {
continue;
}
let matches_relay = relays.iter().any(|r| {
normalize_grasp_server_url(&r.to_string())
.is_ok_and(|r| r.eq(&formatted_as_grasp_server_url))
});
if !matches_relay {
continue;
}
existing_grasp_servers.push(formatted_as_grasp_server_url);
}
existing_grasp_servers
}
pub fn normalize_grasp_server_url(url: &str) -> Result<String> {
let mut parsed = Url::parse(url)
.or_else(|_| Url::parse(&format!("https://{url}")))
.context(format!("{url} not a valid ngit relay URL"))?;
if parsed.host_str().is_none() {
parsed = Url::parse(&format!("https://{url}"))?;
}
let scheme = parsed.scheme();
let host = parsed.host_str().context(format!(
"{url} not a ngit relay url reference: missing host in URL {parsed}"
))?;
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
let path = parsed.path();
let mut normalized_url = match scheme {
"ws" | "http" => format!("http://{host}{port}{path}"),
_ => format!("{host}{port}{path}"),
};
if let Some(pos) = normalized_url.find("npub1") {
normalized_url.truncate(pos); }
Ok(normalized_url.trim_end_matches('/').to_string())
}
pub fn extract_npub(s: &str) -> Result<&str> {
if let Some(start) = s.find("npub1") {
let mut end = start + 5;
while end < s.len() && s[end..=end].chars().all(|c| c.is_ascii_alphanumeric()) {
end += 1;
}
let npub = &s[start..end];
PublicKey::from_bech32(npub).context("invalid npub")?;
Ok(npub)
} else {
bail!("No npub found")
}
}
pub fn is_grasp_server_in_list(url: &str, grasp_servers: &[String]) -> bool {
if !grasp_servers.is_empty() {
grasp_servers
.iter()
.any(|s| s.trim_end_matches('/') == url.trim_end_matches('/'))
} else {
false
}
}
pub fn is_grasp_server_clone_url(url: &str) -> bool {
if !url.starts_with("http://") && !url.starts_with("https://") {
return false;
}
if !url.ends_with(".git") && !url.ends_with(".git/") {
return false;
}
let npub = match extract_npub(url) {
Ok(npub) => npub,
Err(_) => return false,
};
let npub_pattern = format!("/{}/", npub);
if let Some(npub_pos) = url.find(&npub_pattern) {
let after_npub = &url[npub_pos + npub_pattern.len()..];
let after_npub = after_npub.trim_end_matches('/');
if after_npub.is_empty() || after_npub == ".git" {
return false;
}
if !after_npub.ends_with(".git") {
return false;
}
let repo_name = &after_npub[..after_npub.len() - 4]; !repo_name.is_empty()
} else {
false
}
}
pub fn format_grasp_server_url_as_relay_url(url: &str) -> Result<String> {
let grasp_server_url = normalize_grasp_server_url(url)?;
if grasp_server_url.contains("http://") {
return Ok(grasp_server_url.replace("http://", "ws://"));
}
Ok(format!("wss://{grasp_server_url}"))
}
pub fn format_grasp_server_url_as_clone_url(
grasp_server: &str,
public_key: &PublicKey,
identifier: &str,
) -> Result<String> {
let grasp_server_url = normalize_grasp_server_url(grasp_server)?;
let prefix = if grasp_server_url.contains("http://") {
""
} else {
"https://"
};
Ok(format!(
"{prefix}{grasp_server_url}/{}/{}.git",
public_key.to_bech32()?,
pct_encode(identifier)
))
}
pub fn latest_event_repo_ref(repo_ref: &RepoRef) -> Option<RepoRef> {
repo_ref
.events
.values()
.max_by_key(|e| e.created_at)
.and_then(|e| RepoRef::try_from((e.clone(), None)).ok())
}
pub fn apply_grasp_infrastructure(
grasp_servers: &[String],
git_servers: &mut Vec<String>,
relays: &mut Vec<String>,
public_key: &PublicKey,
identifier: &str,
) -> Result<()> {
for (grasp_relay_insert_idx, grasp_server) in grasp_servers.iter().enumerate() {
let clone_url = format_grasp_server_url_as_clone_url(grasp_server, public_key, identifier)?;
let grasp_server_clone_root = if clone_url.contains("https://") {
format!("https://{grasp_server}")
} else {
grasp_server.to_string()
};
let matching_positions: Vec<usize> = git_servers
.iter()
.enumerate()
.filter_map(|(idx, url)| {
if url.contains(&grasp_server_clone_root) {
Some(idx)
} else {
None
}
})
.collect();
if matching_positions.is_empty() {
git_servers.push(clone_url);
} else {
git_servers[matching_positions[0]] = clone_url;
for &position in matching_positions.iter().skip(1).rev() {
git_servers.remove(position);
}
}
let relay_url = format_grasp_server_url_as_relay_url(grasp_server)?;
if !relays.contains(&relay_url) {
relays.insert(grasp_relay_insert_idx, relay_url);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use test_utils::*;
use super::*;
async fn create() -> nostr::Event {
RepoRef {
identifier: "123412341".to_string(),
name: "test name".to_string(),
description: "test description".to_string(),
root_commit: "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2".to_string(),
git_server: vec!["https://localhost:1000".to_string()],
web: vec![
"https://exampleproject.xyz".to_string(),
"https://gitworkshop.dev/123".to_string(),
],
relays: vec![
RelayUrl::parse("ws://relay1.io").unwrap(),
RelayUrl::parse("ws://relay2.io").unwrap(),
],
blossoms: vec![],
hashtags: vec![],
trusted_maintainer: TEST_KEY_1_KEYS.public_key(),
maintainers_without_annoucnement: None,
maintainers: vec![TEST_KEY_1_KEYS.public_key(), TEST_KEY_2_KEYS.public_key()],
events: HashMap::new(),
nostr_git_url: None,
}
.to_event(&TEST_KEY_1_SIGNER)
.await
.unwrap()
}
mod try_from {
use super::*;
#[tokio::test]
async fn identifier() {
assert_eq!(
RepoRef::try_from((create().await, None))
.unwrap()
.identifier,
"123412341",
)
}
#[tokio::test]
async fn name() {
assert_eq!(
RepoRef::try_from((create().await, None)).unwrap().name,
"test name",
)
}
#[tokio::test]
async fn description() {
assert_eq!(
RepoRef::try_from((create().await, None))
.unwrap()
.description,
"test description",
)
}
#[tokio::test]
async fn root_commit_is_r_tag() {
assert_eq!(
RepoRef::try_from((create().await, None))
.unwrap()
.root_commit,
"5e664e5a7845cd1373c79f580ca4fe29ab5b34d2",
)
}
mod root_commit_is_empty_if_no_r_tag_which_is_sha1_format {
use nostr::JsonUtil;
use super::*;
async fn create_with_incorrect_first_commit_ref(s: &str) -> nostr::Event {
nostr::Event::from_json(
create()
.await
.as_json()
.replace("5e664e5a7845cd1373c79f580ca4fe29ab5b34d2", s),
)
.unwrap()
}
#[tokio::test]
async fn less_than_40_characters() {
let s = "5e664e5a7845cd1373";
assert_eq!(
RepoRef::try_from((create_with_incorrect_first_commit_ref(s).await, None))
.unwrap()
.root_commit,
"",
)
}
#[tokio::test]
async fn more_than_40_characters() {
let s = "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2111111111";
assert_eq!(
RepoRef::try_from((create_with_incorrect_first_commit_ref(s).await, None))
.unwrap()
.root_commit,
"",
)
}
#[tokio::test]
async fn not_hex_characters() {
let s = "xxx64e5a7845cd1373c79f580ca4fe29ab5b34d2";
assert_eq!(
RepoRef::try_from((create_with_incorrect_first_commit_ref(s).await, None))
.unwrap()
.root_commit,
"",
)
}
}
#[tokio::test]
async fn git_server() {
assert_eq!(
RepoRef::try_from((create().await, None))
.unwrap()
.git_server,
vec!["https://localhost:1000"],
)
}
#[tokio::test]
async fn web() {
assert_eq!(
RepoRef::try_from((create().await, None)).unwrap().web,
vec![
"https://exampleproject.xyz".to_string(),
"https://gitworkshop.dev/123".to_string()
],
)
}
#[tokio::test]
async fn relays() {
assert_eq!(
RepoRef::try_from((create().await, None)).unwrap().relays,
vec![
RelayUrl::parse("ws://relay1.io").unwrap(),
RelayUrl::parse("ws://relay2.io").unwrap(),
],
)
}
#[tokio::test]
async fn maintainers() {
assert_eq!(
RepoRef::try_from((create().await, None))
.unwrap()
.maintainers,
vec![TEST_KEY_1_KEYS.public_key(), TEST_KEY_2_KEYS.public_key()],
)
}
}
mod to_event {
use super::*;
mod tags {
use super::*;
#[tokio::test]
async fn identifier() {
assert!(
create()
.await
.tags
.iter()
.any(|t| t.as_slice()[0].eq("d") && t.as_slice()[1].eq("123412341"))
)
}
#[tokio::test]
async fn name() {
assert!(
create()
.await
.tags
.iter()
.any(|t| t.as_slice()[0].eq("name") && t.as_slice()[1].eq("test name"))
)
}
#[tokio::test]
async fn alt() {
assert!(create().await.tags.iter().any(|t| t.as_slice()[0].eq("alt")
&& t.as_slice()[1].eq("git repository: test name")))
}
#[tokio::test]
async fn description() {
assert!(
create()
.await
.tags
.iter()
.any(|t| t.as_slice()[0].eq("description")
&& t.as_slice()[1].eq("test description"))
)
}
#[tokio::test]
async fn root_commit_as_reference() {
assert!(create().await.tags.iter().any(|t| t.as_slice()[0].eq("r")
&& t.as_slice()[1].eq("5e664e5a7845cd1373c79f580ca4fe29ab5b34d2")))
}
#[tokio::test]
async fn git_server() {
assert!(
create()
.await
.tags
.iter()
.any(|t| t.as_slice()[0].eq("clone")
&& t.as_slice()[1].eq("https://localhost:1000"))
)
}
#[tokio::test]
async fn relays() {
let event = create().await;
let relays_tag: &nostr::Tag = event
.tags
.iter()
.find(|t| t.as_slice()[0].eq("relays"))
.unwrap();
assert_eq!(relays_tag.as_slice().len(), 3);
assert_eq!(relays_tag.as_slice()[1], "ws://relay1.io");
assert_eq!(relays_tag.as_slice()[2], "ws://relay2.io");
}
#[tokio::test]
async fn web() {
let event = create().await;
let web_tag: &nostr::Tag = event
.tags
.iter()
.find(|t| t.as_slice()[0].eq("web"))
.unwrap();
assert_eq!(web_tag.as_slice().len(), 3);
assert_eq!(web_tag.as_slice()[1], "https://exampleproject.xyz");
assert_eq!(web_tag.as_slice()[2], "https://gitworkshop.dev/123");
}
#[tokio::test]
async fn maintainers() {
let event = create().await;
let maintainers_tag: &nostr::Tag = event
.tags
.iter()
.find(|t| t.as_slice()[0].eq("maintainers"))
.unwrap();
assert_eq!(maintainers_tag.as_slice().len(), 3);
assert_eq!(
maintainers_tag.as_slice()[1],
TEST_KEY_1_KEYS.public_key().to_string()
);
assert_eq!(
maintainers_tag.as_slice()[2],
TEST_KEY_2_KEYS.public_key().to_string()
);
}
#[tokio::test]
async fn no_other_tags() {
assert_eq!(create().await.tags.len(), 9)
}
}
}
#[test]
fn normalize_grasp_server_url_all_checks() -> Result<()> {
let test_cases = vec![
("https://sub.domain.org", "sub.domain.org"),
("wss://sub.domain.org", "sub.domain.org"),
("sub.domain.org", "sub.domain.org"),
("http://sub.domain.org", "http://sub.domain.org"),
("ws://sub.domain.org", "http://sub.domain.org"),
("http://localhost", "http://localhost"),
("localhost", "localhost"),
("https://sub.domain.org:8080", "sub.domain.org:8080"),
("http://sub.domain.org:8080", "http://sub.domain.org:8080"),
("sub.domain.org:8080", "sub.domain.org:8080"),
("https://sub.domain.org/path/to", "sub.domain.org/path/to"),
(
"https://sub.domain.org:8080/path/to",
"sub.domain.org:8080/path/to",
),
(
"https://sub.domain.org/npub143675782648/to.git",
"sub.domain.org",
),
(
"https://sub.domain.org/path/npub143675782648/to.git",
"sub.domain.org/path",
),
("https://sub.domain.org/", "sub.domain.org"),
("http://sub.domain.org/", "http://sub.domain.org"),
];
for (input, expected) in test_cases {
let normalized = normalize_grasp_server_url(input)?;
assert_eq!(normalized, expected);
}
Ok(())
}
mod is_grasp_server_in_list {
use super::*;
#[test]
fn detects_in_list() {
assert!(is_grasp_server_in_list(
"https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/example-repo.git",
&[
"https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/example-repo.git".to_string(),
"https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/example-repo2.git".to_string(),
],
))
}
#[test]
fn ignores_not_in_list() {
assert!(!is_grasp_server_in_list(
"https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/example-repo3.git",
&[
"https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/example-repo.git".to_string(),
"https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/example-repo2.git".to_string(),
],
))
}
}
mod is_grasp_server_clone_url {
use super::*;
#[test]
fn valid_https_url() {
assert!(is_grasp_server_clone_url(
"https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/my-repo.git"
));
}
#[test]
fn valid_http_url() {
assert!(is_grasp_server_clone_url(
"http://localhost:8080/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/test-repo.git"
));
}
#[test]
fn valid_with_trailing_slash() {
assert!(is_grasp_server_clone_url(
"https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/my-repo.git/"
));
}
#[test]
fn valid_with_nested_path() {
assert!(is_grasp_server_clone_url(
"https://relay.ngit.dev/path/to/server/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/my-repo.git"
));
}
#[test]
fn valid_with_port() {
assert!(is_grasp_server_clone_url(
"https://relay.ngit.dev:8080/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/my-repo.git"
));
}
#[test]
fn invalid_missing_git_extension() {
assert!(!is_grasp_server_clone_url(
"https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/my-repo"
));
}
#[test]
fn invalid_no_npub() {
assert!(!is_grasp_server_clone_url(
"https://relay.ngit.dev/my-repo.git"
));
}
#[test]
fn invalid_npub_not_in_path() {
assert!(!is_grasp_server_clone_url(
"https://relay.ngit.dev/my-repo.git?npub=npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr"
));
}
#[test]
fn invalid_wrong_protocol() {
assert!(!is_grasp_server_clone_url(
"ftp://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/my-repo.git"
));
}
#[test]
fn invalid_no_protocol() {
assert!(!is_grasp_server_clone_url(
"relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/my-repo.git"
));
}
#[test]
fn invalid_wss_protocol() {
assert!(!is_grasp_server_clone_url(
"wss://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/my-repo.git"
));
}
#[test]
fn invalid_npub_not_followed_by_slash() {
assert!(!is_grasp_server_clone_url(
"https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejrmy-repo.git"
));
}
#[test]
fn invalid_no_repo_name_after_npub() {
assert!(!is_grasp_server_clone_url(
"https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/.git"
));
}
#[test]
fn invalid_empty_repo_name() {
assert!(!is_grasp_server_clone_url(
"https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr.git"
));
}
#[test]
fn invalid_malformed_npub() {
assert!(!is_grasp_server_clone_url(
"https://relay.ngit.dev/npub123invalid/my-repo.git"
));
}
#[test]
fn valid_repo_name_with_hyphens() {
assert!(is_grasp_server_clone_url(
"https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/my-awesome-repo.git"
));
}
#[test]
fn valid_repo_name_with_underscores() {
assert!(is_grasp_server_clone_url(
"https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/my_repo.git"
));
}
#[test]
fn valid_repo_name_with_numbers() {
assert!(is_grasp_server_clone_url(
"https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/repo123.git"
));
}
}
}