From 4c6578ccb1d285cccbc80c0c2baa26793d3a32f5 Mon Sep 17 00:00:00 2001
From: Awiteb
Date: Sun, 1 Jun 2025 12:00:49 +0000
Subject: [PATCH] feat: Support relays and naddrs sets
This commit introduces support for 'sets', a feature that simplifies
referencing multiple relays or naddrs with a single word.
Sets are stored in the config directory under `n34` as `config.toml` in
`TOML` format. The config path follows the `$XDG_CONFIG_HOME` standard,
defaulting to `$HOME/.config` if unset.
The `n34 sets` command allows managing sets, adding, removing, updating,
or displaying them. When using the main `--relays` flag, a set name can
be provided to automatically expand its relays (an error occurs if the
set contains no relays). Similarly, any command accepting naddrs can
reference a set to extract its naddrs.
Sets can also be merged by updating a set while specifying another
set in `--repo` merges their naddrs, while `--set-relays` merges their
relays. Removal follows the same pattern.
Signed-off-by: Awiteb
---
CHANGELOG.md | 14 ++
src/cli/commands/issue/new.rs | 33 ++--
src/cli/commands/mod.rs | 30 ++-
src/cli/commands/reply.rs | 26 +--
src/cli/commands/repo/announce.rs | 12 +-
src/cli/commands/repo/view.rs | 27 ++-
src/cli/commands/sets/mod.rs | 57 ++++++
src/cli/commands/sets/new.rs | 59 ++++++
src/cli/commands/sets/remove.rs | 65 +++++++
src/cli/commands/sets/show.rs | 85 +++++++++
src/cli/commands/sets/update.rs | 72 +++++++
src/cli/config.rs | 303 ++++++++++++++++++++++++++++++
src/cli/defaults.rs | 28 +++
src/cli/mod.rs | 11 +-
src/cli/parsers.rs | 61 ++----
src/cli/types.rs | 194 +++++++++++++++++++
src/error.rs | 17 ++
src/main.rs | 9 +-
src/nostr_utils/mod.rs | 4 +-
19 files changed, 1010 insertions(+), 97 deletions(-)
create mode 100644 src/cli/commands/sets/mod.rs
create mode 100644 src/cli/commands/sets/new.rs
create mode 100644 src/cli/commands/sets/remove.rs
create mode 100644 src/cli/commands/sets/show.rs
create mode 100644 src/cli/commands/sets/update.rs
create mode 100644 src/cli/config.rs
create mode 100644 src/cli/defaults.rs
create mode 100644 src/cli/types.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c57ddd..467f24b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,10 +15,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Read the `nostr-address` file in `issue new` command - by Awiteb
- Read the `nostr-address` file in `reply` command - by Awiteb
- A `--quote-to` flag to quote the replied to content in the editor - by Awiteb
+- Enter repository as nip5 - by Awiteb
+- Make the relays list optional - by Awiteb
+- Events and naddrs can starts with `nostr:` - by Awiteb
+- Support relays and naddrs sets - by Awiteb
### Dependencies
- Add `chrono@0.4.41` to the dependencies - by Awiteb
+- Enable `nip05` feature of `nostr` crate - by Awiteb
+- Add `serde@1.0.219`, `dirs@6.0.0` and `toml@0.8.22` - by Awiteb
+
+### Fixed
+
+- Create a valid naddr string - by Awiteb
+
+### Refactor
+
+- Support more than one naddr instead of one - by Awiteb
## [0.1.0] - 2025-05-21
diff --git a/src/cli/commands/issue/new.rs b/src/cli/commands/issue/new.rs
index e68783c..cb4db9b 100644
--- a/src/cli/commands/issue/new.rs
+++ b/src/cli/commands/issue/new.rs
@@ -17,13 +17,15 @@
use clap::{ArgGroup, Args};
use futures::future;
-use nostr::{
- event::{EventBuilder, Tag},
- nips::nip19::Nip19Coordinate,
-};
+use nostr::event::{EventBuilder, Tag};
use crate::{
- cli::{CliOptions, CommandRunner, parsers},
+ cli::{
+ CliConfig,
+ CliOptions,
+ CommandRunner,
+ types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
+ },
error::N34Result,
nostr_utils::{
NostrClient,
@@ -47,12 +49,12 @@ use crate::{
)
)]
pub struct NewArgs {
- /// Repository address in `naddr` format or `/repo_id`. e.g.
- /// `4rs.nl/n34` and `_@4rs.nl/n34`
+ /// Repository address in `naddr` format (`naddr1...`), NIP-05 format
+ /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
- /// If not provided, `n34` will look for the `nostr-address` file.
- #[arg(value_name = "NADDR-OR-NIP05", long = "repo", value_parser = parsers::repo_naddr)]
- naddrs: Option>,
+ /// If omitted, looks for a `nostr-address` file.
+ #[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
+ naddrs: Option>,
/// Markdown content for the issue. Cannot be used together with the
/// `--editor` flag.
#[arg(short, long)]
@@ -95,9 +97,14 @@ impl NewArgs {
impl CommandRunner for NewArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
- let client = NostrClient::init(&options).await;
+ let config = CliConfig::load_toml(&options.config_path)?;
+ let naddrs = utils::naddrs_or_file(
+ self.naddrs.flat_naddrs(&config.sets)?,
+ &utils::nostr_address_path()?,
+ )?;
+ let relays = options.relays.clone().flat_relays(&config.sets)?;
+ let client = NostrClient::init(&options, &relays).await;
let user_pubk = options.pubkey().await?;
- let naddrs = utils::naddrs_or_file(self.naddrs.clone(), &utils::nostr_address_path()?)?;
let mut naddrs_iter = naddrs.clone().into_iter();
client.add_relays(&naddrs.extract_relays()).await;
@@ -128,7 +135,7 @@ impl CommandRunner for NewArgs {
let event_id = event.id.expect("There is an id");
let write_relays = [
- options.relays,
+ relays,
utils::add_write_relays(relays_list.as_ref()),
client
.fetch_repos(&naddrs.into_coordinates())
diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs
index cbebb77..7616164 100644
--- a/src/cli/commands/mod.rs
+++ b/src/cli/commands/mod.rs
@@ -14,27 +14,34 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
-
/// `issue` subcommands
mod issue;
/// 'reply` command
mod reply;
/// `repo` subcommands
mod repo;
-
+/// `sets` subcommands
+mod sets;
use std::fmt;
+use std::path::PathBuf;
use clap::{ArgGroup, Args, Parser};
use nostr::key::{Keys, PublicKey, SecretKey};
-use nostr::types::RelayUrl;
use self::issue::IssueSubcommands;
use self::reply::ReplyArgs;
use self::repo::RepoSubcommands;
+use self::sets::SetsSubcommands;
use super::traits::CommandRunner;
+use super::types::RelayOrSet;
use crate::error::{N34Error, N34Result};
+/// Default path used when no path is provided via command line arguments.
+///
+/// This is a workaround since Clap doesn't support lazy evaluation of defaults.
+pub const DEFAULT_FALLBACK_PATH: &str = "I_DO_NOT_KNOW_WHY_CLAP_DO_NOT_SUPPORT_LAZY_DEFAULT";
+
// TODO: Make the `signer` group optional
/// The command-line interface options
#[derive(Args, Clone)]
@@ -48,19 +55,29 @@ use crate::error::{N34Error, N34Result};
pub struct CliOptions {
/// Your Nostr secret key
#[arg(short, long)]
- pub secret_key: Option,
+ pub secret_key: Option,
/// Fallbacks relay to write and read from it. Multiple relays can be
/// passed.
#[arg(short, long)]
- pub relays: Vec,
+ pub relays: Vec,
/// Proof of Work difficulty when creatring events
#[arg(long, default_value_t = 0)]
- pub pow: u8,
+ pub pow: u8,
+ /// Config path [default: `$XDG_CONFIG_HOME` or `$HOME/.config`]
+ #[arg(long, value_name = "PATH", default_value = DEFAULT_FALLBACK_PATH,
+ hide_default_value = true
+ )]
+ pub config_path: PathBuf,
}
/// N34 commands
#[derive(Parser, Debug)]
pub enum Commands {
+ /// Manage reposotoies and relays sets
+ Sets {
+ #[command(subcommand)]
+ subcommands: SetsSubcommands,
+ },
/// Manage repositories
Repo {
#[command(subcommand)]
@@ -89,6 +106,7 @@ impl CommandRunner for Commands {
Self::Repo { subcommands } => subcommands.run(options).await,
Self::Issue { subcommands } => subcommands.run(options).await,
Commands::Reply(args) => args.run(options).await,
+ Commands::Sets { subcommands } => subcommands.run(options).await,
}
}
}
diff --git a/src/cli/commands/reply.rs b/src/cli/commands/reply.rs
index 3c32eba..4e13f7b 100644
--- a/src/cli/commands/reply.rs
+++ b/src/cli/commands/reply.rs
@@ -23,14 +23,17 @@ use nostr::{
filter::Filter,
nips::{
nip01::{Coordinate, Metadata},
- nip19::{self, FromBech32, Nip19Coordinate, ToBech32},
+ nip19::{self, FromBech32, ToBech32},
},
types::RelayUrl,
};
use super::{CliOptions, CommandRunner};
use crate::{
- cli::parsers,
+ cli::{
+ CliConfig,
+ types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
+ },
error::{N34Error, N34Result},
nostr_utils::{
NostrClient,
@@ -101,13 +104,12 @@ pub struct ReplyArgs {
/// Quote the replied-to event in the editor
#[arg(long)]
quote_to: bool,
- /// Repository address in `naddr` format or `/repo_id`. e.g.
- /// `4rs.nl/n34` and `_@4rs.nl/n34`
+ /// Repository address in `naddr` format (`naddr1...`), NIP-05 format
+ /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
- /// If not provided, `n34` will look for the `nostr-address` file and if not
- /// found, will get it from the root event if found.
- #[arg(value_name = "NADDR-OR-NIP05", long = "repo", value_parser = parsers::repo_naddr)]
- naddrs: Option>,
+ /// If omitted, looks for a `nostr-address` file.
+ #[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
+ naddrs: Option>,
/// The comment (cannot be used with --editor)
#[arg(short, long)]
comment: Option,
@@ -119,10 +121,12 @@ pub struct ReplyArgs {
impl CommandRunner for ReplyArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let nostr_address_path = utils::nostr_address_path()?;
- let client = NostrClient::init(&options).await;
+ let config = CliConfig::load_toml(&options.config_path)?;
+ let relays = options.relays.clone().flat_relays(&config.sets)?;
+ let client = NostrClient::init(&options, &relays).await;
let user_pubk = options.pubkey().await?;
- let repo_naddrs = if let Some(naddrs) = self.naddrs {
+ let repo_naddrs = if let Some(naddrs) = self.naddrs.flat_naddrs(&config.sets)? {
client.add_relays(&naddrs.extract_relays()).await;
Some(naddrs)
} else if fs::exists(&nostr_address_path).is_ok() {
@@ -176,7 +180,7 @@ impl CommandRunner for ReplyArgs {
let author_read_relays =
utils::add_read_relays(client.user_relays_list(user_pubk).await?.as_ref());
let write_relays = [
- options.relays,
+ relays,
utils::add_write_relays(relays_list.as_ref()),
// Merge repository announcement relays into write relays
repos.extract_relays(),
diff --git a/src/cli/commands/repo/announce.rs b/src/cli/commands/repo/announce.rs
index 73c91fe..fe29a81 100644
--- a/src/cli/commands/repo/announce.rs
+++ b/src/cli/commands/repo/announce.rs
@@ -21,7 +21,7 @@ use futures::future;
use nostr::{event::EventBuilder, key::PublicKey, types::Url};
use crate::{
- cli::{CliOptions, CommandRunner, NOSTR_ADDRESS_FILE},
+ cli::{CliConfig, CliOptions, CommandRunner, NOSTR_ADDRESS_FILE, types::RelayOrSetVecExt},
error::N34Result,
nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils},
};
@@ -81,7 +81,9 @@ impl CommandRunner for AnnounceArgs {
async fn run(mut self, options: CliOptions) -> N34Result<()> {
options.ensure_relays()?;
- let client = NostrClient::init(&options).await;
+ let config = CliConfig::load_toml(&options.config_path)?;
+ let relays = options.relays.clone().flat_relays(&config.sets)?;
+ let client = NostrClient::init(&options, &relays).await;
let user_pubk = options.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).await?;
@@ -89,14 +91,14 @@ impl CommandRunner for AnnounceArgs {
self.maintainers.insert(0, user_pubk);
}
- let naddr = utils::repo_naddr(&self.repo_id, user_pubk, &options.relays)?;
+ let naddr = utils::repo_naddr(&self.repo_id, user_pubk, &relays)?;
let event = EventBuilder::new_git_repo(
self.repo_id,
self.name.map(utils::str_trim),
self.description.map(utils::str_trim),
self.web,
self.clone,
- options.relays.clone(),
+ relays.clone(),
self.maintainers.clone(),
self.label.into_iter().map(utils::str_trim).collect(),
self.force_id,
@@ -125,7 +127,7 @@ impl CommandRunner for AnnounceArgs {
}
let write_relays = [
- options.relays.clone(),
+ relays,
utils::add_write_relays(relays_list.as_ref()),
// Include read relays for each maintainer (if found)
future::join_all(
diff --git a/src/cli/commands/repo/view.rs b/src/cli/commands/repo/view.rs
index ff6f679..f8c4723 100644
--- a/src/cli/commands/repo/view.rs
+++ b/src/cli/commands/repo/view.rs
@@ -17,10 +17,14 @@
use std::fmt;
use clap::Args;
-use nostr::nips::nip19::Nip19Coordinate;
use crate::{
- cli::{CliOptions, CommandRunner, parsers},
+ cli::{
+ CliConfig,
+ CliOptions,
+ CommandRunner,
+ types::{NaddrOrSet, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
+ },
error::N34Result,
nostr_utils::{NostrClient, traits::NaddrsUtils, utils},
};
@@ -28,20 +32,25 @@ use crate::{
/// Arguments for the `repo view` command
#[derive(Args, Debug)]
pub struct ViewArgs {
- /// Repository address in `naddr` format or `/repo_id`. e.g.
- /// `4rs.nl/n34` and `_@4rs.nl/n34`
+ /// Repository address in `naddr` format (`naddr1...`), NIP-05 format
+ /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
///
- /// If not provided, `n34` will look for the `nostr-address` file.
- #[arg(value_name = "NADDR-OR-NIP05", long = "repo", value_parser = parsers::repo_naddr)]
- naddrs: Option>,
+ /// If omitted, looks for a `nostr-address` file.
+ #[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
+ naddrs: Option>,
}
impl CommandRunner for ViewArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
// FIXME: The signer is not required here
- let naddrs = utils::naddrs_or_file(self.naddrs, &utils::nostr_address_path()?)?;
- let client = NostrClient::init(&options).await;
+ let config = CliConfig::load_toml(&options.config_path)?;
+ let naddrs = utils::naddrs_or_file(
+ self.naddrs.flat_naddrs(&config.sets)?,
+ &utils::nostr_address_path()?,
+ )?;
+ let relays = options.relays.clone().flat_relays(&config.sets)?;
+ let client = NostrClient::init(&options, &relays).await;
client.add_relays(&naddrs.extract_relays()).await;
let repos = client.fetch_repos(&naddrs.into_coordinates()).await?;
diff --git a/src/cli/commands/sets/mod.rs b/src/cli/commands/sets/mod.rs
new file mode 100644
index 0000000..11465bd
--- /dev/null
+++ b/src/cli/commands/sets/mod.rs
@@ -0,0 +1,57 @@
+// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
+// Copyright (C) 2025 Awiteb
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+/// `sets new` command
+mod new;
+/// `sets remove` command
+mod remove;
+/// `sets show` commands
+mod show;
+/// `sets update` command
+mod update;
+
+use clap::Subcommand;
+
+use self::new::NewArgs;
+use self::remove::RemoveArgs;
+use self::show::ShowArgs;
+use self::update::UpdateArgs;
+use super::{CliOptions, CommandRunner};
+use crate::error::N34Result;
+
+#[derive(Subcommand, Debug)]
+pub enum SetsSubcommands {
+ /// Remove a set, or specific repos and relays within it
+ Remove(RemoveArgs),
+ /// Create a new set
+ New(NewArgs),
+ /// Modify an existing set
+ Update(UpdateArgs),
+ /// Show a single set or all the stored sets
+ Show(ShowArgs),
+}
+
+
+impl CommandRunner for SetsSubcommands {
+ async fn run(self, options: CliOptions) -> N34Result<()> {
+ match self {
+ Self::Remove(args) => args.run(options).await,
+ Self::New(args) => args.run(options).await,
+ Self::Update(args) => args.run(options).await,
+ Self::Show(args) => args.run(options).await,
+ }
+ }
+}
diff --git a/src/cli/commands/sets/new.rs b/src/cli/commands/sets/new.rs
new file mode 100644
index 0000000..240e6cc
--- /dev/null
+++ b/src/cli/commands/sets/new.rs
@@ -0,0 +1,59 @@
+// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
+// Copyright (C) 2025 Awiteb
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+use clap::Args;
+
+use crate::{
+ cli::{
+ CliConfig,
+ CliOptions,
+ ConfigError,
+ MutRepoRelaySetsExt,
+ traits::CommandRunner,
+ types::{NaddrOrSet, NaddrOrSetVecExt, RelayOrSet, RelayOrSetVecExt},
+ },
+ error::N34Result,
+};
+
+#[derive(Args, Debug)]
+pub struct NewArgs {
+ /// Unique name for the set
+ name: String,
+ /// Optional relay to add it to the set, either as URL or set name to
+ /// extract its relays. [aliases: `--sr`]
+ #[arg(long = "set-relay", alias("sr"))]
+ relays: Vec,
+ /// Repository address in `naddr` format (`naddr1...`), NIP-05 format
+ /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
+ #[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
+ naddrs: Vec,
+}
+
+impl CommandRunner for NewArgs {
+ // FIXME: The signer is not required here
+ async fn run(self, options: CliOptions) -> N34Result<()> {
+ let mut config = CliConfig::load_toml(&options.config_path)?;
+ let naddrs = self.naddrs.flat_naddrs(&config.sets)?;
+ let relays = self.relays.flat_relays(&config.sets)?;
+
+ if relays.is_empty() && naddrs.is_empty() {
+ return Err(ConfigError::NewEmptySet.into());
+ }
+
+ config.sets.push_set(self.name, naddrs, relays)?;
+ config.dump_toml(&options.config_path)
+ }
+}
diff --git a/src/cli/commands/sets/remove.rs b/src/cli/commands/sets/remove.rs
new file mode 100644
index 0000000..3da6422
--- /dev/null
+++ b/src/cli/commands/sets/remove.rs
@@ -0,0 +1,65 @@
+// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
+// Copyright (C) 2025 Awiteb
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+use clap::Args;
+
+use crate::{
+ cli::{
+ CliConfig,
+ CliOptions,
+ MutRepoRelaySetsExt,
+ traits::CommandRunner,
+ types::{NaddrOrSet, NaddrOrSetVecExt, RelayOrSet, RelayOrSetVecExt},
+ },
+ error::N34Result,
+};
+
+#[derive(Args, Debug)]
+pub struct RemoveArgs {
+ /// Set name to delete
+ name: String,
+ /// Specific relay to remove it from the set, either as URL or set name to
+ /// extract its relays. [aliases: `--sr`]
+ #[arg(long = "set-relay", alias("sr"))]
+ relays: Vec,
+
+ /// Repository address in `naddr` format (`naddr1...`), NIP-05 format
+ /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
+ #[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
+ naddrs: Vec,
+}
+
+impl CommandRunner for RemoveArgs {
+ // FIXME: The signer is not required here
+ async fn run(self, options: CliOptions) -> N34Result<()> {
+ let mut config = CliConfig::load_toml(&options.config_path)?;
+ let naddrs = self.naddrs.flat_naddrs(&config.sets)?;
+ let relays = self.relays.flat_relays(&config.sets)?;
+
+ if relays.is_empty() && naddrs.is_empty() {
+ config.sets.remove_set(self.name)?;
+ } else {
+ if !relays.is_empty() {
+ config.sets.remove_relays(&self.name, relays.into_iter())?;
+ }
+ if !naddrs.is_empty() {
+ config.sets.remove_naddrs(self.name, naddrs.into_iter())?;
+ }
+ }
+
+ config.dump_toml(&options.config_path)
+ }
+}
diff --git a/src/cli/commands/sets/show.rs b/src/cli/commands/sets/show.rs
new file mode 100644
index 0000000..9abbae8
--- /dev/null
+++ b/src/cli/commands/sets/show.rs
@@ -0,0 +1,85 @@
+// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
+// Copyright (C) 2025 Awiteb
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+use clap::Args;
+use nostr::nips::nip19::ToBech32;
+
+use crate::{
+ cli::{CliConfig, CliOptions, RepoRelaySet, RepoRelaySetsExt, traits::CommandRunner},
+ error::N34Result,
+};
+
+#[derive(Args, Debug)]
+pub struct ShowArgs {
+ /// Name of the set to display. If not provided, lists all available sets.
+ name: Option,
+}
+
+impl CommandRunner for ShowArgs {
+ // FIXME: The signer is not required here
+ async fn run(self, options: CliOptions) -> N34Result<()> {
+ let config = CliConfig::load_toml(&options.config_path)?;
+
+ if let Some(name) = self.name {
+ println!("{}", format_set(config.sets.as_slice().get_set(&name)?));
+ } else {
+ println!(
+ "{}",
+ config
+ .sets
+ .iter()
+ .map(format_set)
+ .collect::>()
+ .join("\n----------\n")
+ );
+ }
+
+ Ok(())
+ }
+}
+
+/// Format a set to view it to the user
+fn format_set(set: &RepoRelaySet) -> String {
+ let naddrs = if set.naddrs.is_empty() {
+ "Nothing".to_owned()
+ } else {
+ format!(
+ "\n- {}",
+ set.naddrs
+ .iter()
+ .map(|naddr| naddr.to_bech32().expect("We did decoded before"))
+ .collect::>()
+ .join("\n- ")
+ )
+ };
+ let relays = if set.relays.is_empty() {
+ "Nothing".to_owned()
+ } else {
+ format!(
+ "\n- {}",
+ set.relays
+ .iter()
+ .map(|relay| relay.to_string())
+ .collect::>()
+ .join("\n- ")
+ )
+ };
+
+ format!(
+ "Name: {}\nّّRepositories: {naddrs}\nRelays: {relays}",
+ set.name
+ )
+}
diff --git a/src/cli/commands/sets/update.rs b/src/cli/commands/sets/update.rs
new file mode 100644
index 0000000..4bfb36c
--- /dev/null
+++ b/src/cli/commands/sets/update.rs
@@ -0,0 +1,72 @@
+// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
+// Copyright (C) 2025 Awiteb
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+use std::collections::HashSet;
+
+use clap::Args;
+
+use crate::{
+ cli::{
+ CliConfig,
+ CliOptions,
+ MutRepoRelaySetsExt,
+ traits::CommandRunner,
+ types::{NaddrOrSet, NaddrOrSetVecExt, RelayOrSet, RelayOrSetVecExt},
+ },
+ error::N34Result,
+};
+
+#[derive(Args, Debug)]
+pub struct UpdateArgs {
+ /// Name of the set to update
+ name: String,
+ /// Add relay to the set, either as URL or set name to extract its relays.
+ /// [aliases: `--sr`]
+ #[arg(long = "set-relay", alias("sr"))]
+ relays: Vec,
+ /// Repository address in `naddr` format (`naddr1...`), NIP-05 format
+ /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`.
+ #[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")]
+ naddrs: Vec,
+ /// Replace existing relays/repositories instead of adding to them
+ #[arg(long = "override")]
+ override_set: bool,
+}
+
+impl CommandRunner for UpdateArgs {
+ // FIXME: The signer is not required here
+ async fn run(self, options: CliOptions) -> N34Result<()> {
+ let mut config = CliConfig::load_toml(&options.config_path)?;
+ let naddrs = self.naddrs.flat_naddrs(&config.sets)?;
+ let relays = self.relays.flat_relays(&config.sets)?;
+
+ let set = config.sets.get_mut_set(&self.name)?;
+
+ if self.override_set {
+ if !relays.is_empty() {
+ set.relays = HashSet::from_iter(relays);
+ }
+ if !naddrs.is_empty() {
+ set.naddrs = HashSet::from_iter(naddrs)
+ }
+ } else {
+ set.relays.extend(relays);
+ set.naddrs.extend(naddrs);
+ }
+
+ config.dump_toml(&options.config_path)
+ }
+}
diff --git a/src/cli/config.rs b/src/cli/config.rs
new file mode 100644
index 0000000..6cff549
--- /dev/null
+++ b/src/cli/config.rs
@@ -0,0 +1,303 @@
+// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
+// Copyright (C) 2025 Awiteb
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+use std::{collections::HashSet, fs, path::Path};
+
+use nostr::{
+ nips::nip19::{FromBech32, Nip19Coordinate, ToBech32},
+ types::RelayUrl,
+};
+use serde::{Deserialize, Serialize, Serializer};
+
+use crate::error::{N34Error, N34Result};
+
+/// Errors that can occur when working with configuration files.
+#[derive(thiserror::Error, Debug)]
+pub enum ConfigError {
+ #[error(
+ "Could not determine the default config path: both `$XDG_CONFIG_HOME` and `$HOME` \
+ environment variables are missing or unset."
+ )]
+ CanNotFindConfigPath,
+ #[error("Couldn't read the config file: {0}")]
+ ReadFile(std::io::Error),
+ #[error("Couldn't write in the config file: {0}")]
+ WriteFile(std::io::Error),
+ #[error("Couldn't serialize the config. This is a bug, please report it: {0}")]
+ Serialize(toml::ser::Error),
+ #[error("Failed to parse the config file: {0}")]
+ ParseFile(toml::de::Error),
+ #[error("Duplicate configuration set name detected: '{0}'. Each set must have a unique name.")]
+ SetDuplicateName(String),
+ #[error("No set with the given name `{0}`")]
+ SetNotFound(String),
+ #[error("You can't create an new empty set.")]
+ NewEmptySet,
+}
+
+/// Configuration for the command-line interface.
+#[derive(serde::Serialize, serde::Deserialize, Clone, Default, Debug)]
+pub struct CliConfig {
+ /// Groups of repositories and relays.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub sets: Vec,
+}
+
+/// A named group of repositories and relays.
+#[derive(serde::Serialize, serde::Deserialize, Default, Clone, Debug)]
+pub struct RepoRelaySet {
+ /// Unique identifier for this group.
+ pub name: String,
+ /// Repository addresses in this group.
+ #[serde(
+ default,
+ skip_serializing_if = "HashSet::is_empty",
+ serialize_with = "ser_naddrs",
+ deserialize_with = "de_naddrs"
+ )]
+ pub naddrs: HashSet,
+ /// Relay URLs in this group.
+ #[serde(default, skip_serializing_if = "HashSet::is_empty")]
+ pub relays: HashSet,
+}
+
+impl CliConfig {
+ /// Reads and parse a TOML config file from the given path, creating it if
+ /// missing.
+ pub fn load_toml(file_path: &Path) -> N34Result {
+ tracing::info!(path = %file_path.display(), "Loading configuration from file");
+ // Make sure the file is exist
+ if let Some(parent) = file_path.parent() {
+ if !parent.exists() {
+ fs::create_dir_all(parent)?;
+ }
+ }
+ let _ = fs::File::create_new(file_path);
+
+ let mut config: Self =
+ toml::from_str(&fs::read_to_string(file_path).map_err(ConfigError::ReadFile)?)
+ .map_err(ConfigError::ParseFile)?;
+
+ config.post_sets()?;
+
+ Ok(config)
+ }
+
+ /// Dump the config as toml in a file
+ pub fn dump_toml(mut self, file_path: &Path) -> N34Result<()> {
+ tracing::debug!(config = ?self, "Writing configuration to {}", file_path.display());
+ self.post_sets()?;
+
+ fs::write(
+ file_path,
+ toml::to_string_pretty(&self).map_err(ConfigError::Serialize)?,
+ )
+ .map_err(ConfigError::WriteFile)?;
+
+ Ok(())
+ }
+
+ /// Performs post-processing validation on the sets after loading or before
+ /// dumping.
+ fn post_sets(&mut self) -> N34Result<()> {
+ self.sets.as_slice().ensure_names()?;
+ self.sets.dedup_naddrs();
+
+ Ok(())
+ }
+}
+
+impl RepoRelaySet {
+ /// Create a new [`RepoRelaySet`]
+ pub fn new(
+ name: impl Into,
+ naddrs: impl IntoIterator- ,
+ relays: impl IntoIterator
- ,
+ ) -> Self {
+ Self {
+ name: name.into(),
+ naddrs: HashSet::from_iter(naddrs),
+ relays: HashSet::from_iter(relays),
+ }
+ }
+
+ /// Removes duplicate repository addresses by comparing their coordinates,
+ /// ignoring embedded relays.
+ pub fn dedup_naddrs(&mut self) {
+ let mut seen = HashSet::new();
+ self.naddrs.retain(|n| seen.insert(n.coordinate.clone()));
+ }
+}
+
+#[easy_ext::ext(MutRepoRelaySetsExt)]
+impl Vec {
+ /// Removes duplicate repository addresses from each set.
+ ///
+ /// Relays are automatically deduplicated by the HashSet, but
+ /// repository addresses may appear duplicated if relays are sorted
+ /// differently or when relay counts vary. This compares addresses by
+ /// their coordinates, ignoring any embedded relay details.
+ pub fn dedup_naddrs(&mut self) {
+ self.iter_mut().for_each(RepoRelaySet::dedup_naddrs);
+ }
+
+ /// Finds and returns a mutable reference a set with the given name. Returns
+ /// an error if no set with this name exists.
+ pub fn get_mut_set(&mut self, name: impl AsRef) -> N34Result<&mut RepoRelaySet> {
+ let name = name.as_ref();
+ let set = self
+ .iter_mut()
+ .find(|set| set.name == name)
+ .ok_or_else(|| N34Error::from(ConfigError::SetNotFound(name.to_owned())))?;
+
+ tracing::trace!(
+ name = %name, set = ?set,
+ "Successfully located a set with the giving name"
+ );
+
+ Ok(set)
+ }
+
+ /// Creates and pushes a new set with the given name.
+ ///
+ /// Returns an error if a set with the same name already exists.
+ pub fn push_set(
+ &mut self,
+ name: impl Into,
+ repos: impl IntoIterator
- ,
+ relays: impl IntoIterator
- ,
+ ) -> N34Result<()> {
+ let set_name: String = name.into();
+ tracing::trace!(sets = ?self, "Pushing set '{set_name}' to sets collection");
+
+ if self.as_slice().exists(&set_name) {
+ return Err(ConfigError::SetDuplicateName(set_name).into());
+ }
+
+ self.push(RepoRelaySet::new(set_name, repos, relays));
+
+ Ok(())
+ }
+
+ /// Removes the set with the given name if it exists. Returns an error if
+ /// the set is not found.
+ pub fn remove_set(&mut self, name: impl Into) -> N34Result<()> {
+ let set_name: String = name.into();
+ tracing::trace!(set_name, sets = ?self, "Removing set '{set_name}' from sets collection");
+
+ if !self.as_slice().exists(&set_name) {
+ return Err(ConfigError::SetNotFound(set_name).into());
+ }
+
+ self.retain(|s| s.name != set_name);
+
+ Ok(())
+ }
+
+ /// Removes the given relays from the specified set.
+ pub fn remove_relays(
+ &mut self,
+ name: impl Into,
+ relays: impl Iterator
- ,
+ ) -> N34Result<()> {
+ let relays = Vec::from_iter(relays);
+ let set = self.get_mut_set(name.into())?;
+
+ set.relays.retain(|r| !relays.contains(r));
+
+ Ok(())
+ }
+
+ /// Removes the given naddrs from the specified set.
+ pub fn remove_naddrs(
+ &mut self,
+ name: impl Into,
+ naddrs: impl Iterator
- ,
+ ) -> N34Result<()> {
+ let coordinates = Vec::from_iter(naddrs.map(|n| n.coordinate));
+ let set = self.get_mut_set(name.into())?;
+
+ set.naddrs.retain(|n| !coordinates.contains(&n.coordinate));
+
+ Ok(())
+ }
+}
+
+#[easy_ext::ext(RepoRelaySetsExt)]
+impl &[RepoRelaySet] {
+ /// Checks for duplicate set names. Returns an error if any duplicates are
+ /// found.
+ pub fn ensure_names(&self) -> N34Result<()> {
+ let mut names = Vec::with_capacity(self.len());
+ names.extend(self.iter().map(|s| s.name.to_owned()));
+
+ names.sort_unstable();
+
+ if let Some(duplicate) = duplicate_in_sorted(&names) {
+ return Err(ConfigError::SetDuplicateName(duplicate.clone()).into());
+ }
+ Ok(())
+ }
+
+ /// Check if a set with the given name exists.
+ pub fn exists(&self, set_name: &str) -> bool {
+ self.iter().any(|set| set.name == set_name)
+ }
+
+ /// Finds and returns a reference a set with the given name. Returns an
+ /// error if no set with this name exists.
+ pub fn get_set(&self, name: impl AsRef) -> N34Result<&RepoRelaySet> {
+ let name = name.as_ref();
+ let set = self
+ .iter()
+ .find(|set| set.name == name)
+ .ok_or_else(|| N34Error::from(ConfigError::SetNotFound(name.to_owned())))?;
+ tracing::trace!(
+ name = %name, set = ?set,
+ "Successfully located a set with the giving name"
+ );
+ Ok(set)
+ }
+}
+
+/// Helper function that checks for duplicates in a sorted slice
+fn duplicate_in_sorted(items: &[T]) -> Option<&T> {
+ items.windows(2).find(|w| w[0] == w[1]).map(|w| &w[0])
+}
+
+fn ser_naddrs
(naddr: &HashSet, serializer: S) -> Result
+where
+ S: Serializer,
+{
+ let str_naddrs = naddr
+ .iter()
+ .map(|n| n.to_bech32().map_err(|err| err.to_string()))
+ .collect::, _>>()
+ .map_err(serde::ser::Error::custom)?;
+
+ str_naddrs.serialize(serializer)
+}
+
+fn de_naddrs<'de, D>(deserializer: D) -> Result, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ Vec::::deserialize(deserializer)?
+ .into_iter()
+ .map(|naddr| Nip19Coordinate::from_bech32(&naddr))
+ .collect::, _>>()
+ .map_err(serde::de::Error::custom)
+}
diff --git a/src/cli/defaults.rs b/src/cli/defaults.rs
new file mode 100644
index 0000000..e6ba0bf
--- /dev/null
+++ b/src/cli/defaults.rs
@@ -0,0 +1,28 @@
+// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
+// Copyright (C) 2025 Awiteb
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+use std::path::PathBuf;
+
+use crate::{cli::ConfigError, error::N34Result};
+
+
+/// Default config path
+pub fn config_path() -> N34Result {
+ Ok(dirs::config_dir()
+ .ok_or(ConfigError::CanNotFindConfigPath)?
+ .join("n34")
+ .join("config.toml"))
+}
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index 0b4ad35..e1c6318 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -15,16 +15,23 @@
// along with this program. If not, see .
/// Commands module
-mod commands;
+pub mod commands;
+/// The CLI config
+pub mod config;
+/// Default lazy values for CLI arguments
+pub mod defaults;
/// CLI arguments parsers
pub mod parsers;
/// CLI traits
-mod traits;
+pub mod traits;
+/// Common helper types used throughout the CLI.
+pub mod types;
use clap::Parser;
use clap_verbosity_flag::Verbosity;
pub use self::commands::*;
+pub use self::config::*;
use self::traits::CommandRunner;
use crate::error::N34Result;
diff --git a/src/cli/parsers.rs b/src/cli/parsers.rs
index 1db4f6f..0d4d819 100644
--- a/src/cli/parsers.rs
+++ b/src/cli/parsers.rs
@@ -18,35 +18,16 @@ use std::{fs, path::Path};
use nostr::{
Kind,
- nips::{
- self,
- nip01::Coordinate,
- nip19::{FromBech32, Nip19Coordinate},
- },
+ nips::nip19::{FromBech32, Nip19Coordinate},
};
-use tokio::runtime::Handle;
-use crate::error::{N34Error, N34Result};
+use super::Cli;
+use crate::{
+ cli::DEFAULT_FALLBACK_PATH,
+ error::{N34Error, N34Result},
+};
-fn parse_nip5_repo(nip5: &str, repo_id: &str) -> Result {
- let (username, domain) = nip5.split_once("@").unwrap_or(("_", nip5));
-
- let nip5_profile = tokio::task::block_in_place(|| {
- Handle::current().block_on(async {
- nips::nip05::profile(format!("{username}@{domain}"), None)
- .await
- .map_err(|err| err.to_string())
- })
- })?;
-
- Ok(Nip19Coordinate::new(
- Coordinate::new(Kind::GitRepoAnnouncement, nip5_profile.public_key).identifier(repo_id),
- nip5_profile.relays,
- )
- .expect("The relays is `RelayUrl`"))
-}
-
-fn parse_repo_naddr(repo_naddr: &str) -> Result {
+pub fn parse_repo_naddr(repo_naddr: &str) -> Result {
let naddr = Nip19Coordinate::from_bech32(repo_naddr).map_err(|err| err.to_string())?;
if naddr.relays.is_empty() {
tracing::warn!("The repository naddr does not contain any relay hints");
@@ -74,27 +55,11 @@ pub fn parse_nostr_address_file(file_path: &Path) -> N34Result Result {
- let repo = repo.trim().trim_start_matches("nostr:");
-
- if repo.contains("/") {
- let (nip5, repo_id) = repo.split_once("/").expect("There is a `/`");
- parse_nip5_repo(nip5, repo_id)
- } else if repo.starts_with("naddr1") {
- parse_repo_naddr(repo)
- } else {
- Err(
- "Invalid repository address format. It can be A NIP-05 identifier with repository ID \
- in format `/` or A valid naddr1 string (NIP-19)"
- .to_owned(),
- )
+/// Post parse cli arguments
+pub fn post_parse_cli(mut cli: Cli) -> N34Result {
+ if let Some(DEFAULT_FALLBACK_PATH) = cli.options.config_path.to_str() {
+ cli.options.config_path = super::defaults::config_path()?;
}
+
+ Ok(cli)
}
diff --git a/src/cli/types.rs b/src/cli/types.rs
new file mode 100644
index 0000000..4259b03
--- /dev/null
+++ b/src/cli/types.rs
@@ -0,0 +1,194 @@
+// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
+// Copyright (C) 2025 Awiteb
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+use std::str::FromStr;
+
+use nostr::{
+ event::Kind,
+ nips::{self, nip01::Coordinate, nip19::Nip19Coordinate},
+ types::RelayUrl,
+};
+use tokio::runtime::Handle;
+
+use super::{RepoRelaySetsExt, parsers};
+use crate::{
+ cli::RepoRelaySet,
+ error::{N34Error, N34Result},
+};
+
+/// Either a NIP-19 coordinate (naddr) or a named set.
+#[derive(Debug, Clone)]
+pub enum NaddrOrSet {
+ /// NIP-19 coordinate.
+ Naddr(Nip19Coordinate),
+ /// Name of a set (may not exist).
+ Set(String),
+}
+
+/// Either relay URL or a named set.
+#[derive(Debug, Clone)]
+pub enum RelayOrSet {
+ /// Relay URL.
+ Relay(RelayUrl),
+ /// Name of a set (may not exist).
+ Set(String),
+}
+
+impl NaddrOrSet {
+ /// Returns the naddr if `Naddr` or try to get the relays from the set.
+ /// Returns error if the set naddrs are empty or the set not found.
+ pub fn get_naddrs(self, sets: &[RepoRelaySet]) -> N34Result> {
+ match self {
+ Self::Naddr(nip19_coordinate) => Ok(vec![nip19_coordinate]),
+ Self::Set(name) => {
+ let set = sets
+ .get_set(&name)
+ .map_err(|_| N34Error::InvalidNaddrArg(name.clone()))?;
+ if set.naddrs.is_empty() {
+ Err(N34Error::EmptySetNaddrs(name))
+ } else {
+ Ok(Vec::from_iter(set.naddrs.clone()))
+ }
+ }
+ }
+ }
+}
+
+
+impl RelayOrSet {
+ /// Returns the relay if `Relay` or try to get the relays from the set.
+ /// Returns error if the set relays are empty or the set not found
+ pub fn get_relays(self, sets: &[RepoRelaySet]) -> N34Result> {
+ match self {
+ Self::Relay(relay) => Ok(vec![relay]),
+ Self::Set(name) => {
+ let set = sets.get_set(&name)?;
+ if set.relays.is_empty() {
+ Err(N34Error::EmptySetRelays(name))
+ } else {
+ Ok(Vec::from_iter(set.relays.clone()))
+ }
+ }
+ }
+ }
+}
+
+impl FromStr for NaddrOrSet {
+ type Err = String;
+
+ /// Parses a Git repository address which can be either:
+ /// - A bech32-encoded naddr (e.g. "naddr1...") for Git repository
+ /// announcements (kind 30617)
+ /// - A NIP-05 identifier with repository ID (e.g. "4rs.nl/n34" or
+ /// "_@4rs.nl/n34")
+ /// - A set name.
+ ///
+ /// Returns an error for invalid formats, failed bech32 decoding, wrong
+ /// event kind.
+ fn from_str(naddr_or_set: &str) -> Result {
+ let naddr_or_set = naddr_or_set.trim();
+
+ if naddr_or_set.contains("/") {
+ let (nip5, repo_id) = naddr_or_set.split_once("/").expect("There is a `/`");
+ parse_nip5_repo(nip5, repo_id)
+ } else if naddr_or_set.starts_with("naddr1") || naddr_or_set.starts_with("nostr:naddr1") {
+ parsers::parse_repo_naddr(naddr_or_set.trim_start_matches("nostr:")).map(Self::Naddr)
+ } else {
+ Ok(Self::Set(naddr_or_set.to_owned()))
+ }
+ }
+}
+
+impl FromStr for RelayOrSet {
+ type Err = String;
+
+ /// Parse a string into a relay URL or a set name.
+ /// If the string is a valid URL (e.g., "wss://example.com"), it's treated
+ /// as a relay URL. Otherwise, it's treated as a set name, and its
+ /// associated relays will be merged.
+ fn from_str(relay_or_set: &str) -> Result {
+ let relay_or_set = relay_or_set.trim();
+
+ if relay_or_set.starts_with("wss://") {
+ RelayUrl::from_str(relay_or_set)
+ .map_err(|err| err.to_string())
+ .map(Self::Relay)
+ } else {
+ Ok(Self::Set(relay_or_set.to_owned()))
+ }
+ }
+}
+
+fn parse_nip5_repo(nip5: &str, repo_id: &str) -> Result {
+ let (username, domain) = nip5.split_once("@").unwrap_or(("_", nip5));
+
+ let nip5_profile = tokio::task::block_in_place(|| {
+ Handle::current().block_on(async {
+ nips::nip05::profile(format!("{username}@{domain}"), None)
+ .await
+ .map_err(|err| err.to_string())
+ })
+ })?;
+
+ Ok(NaddrOrSet::Naddr(
+ Nip19Coordinate::new(
+ Coordinate::new(Kind::GitRepoAnnouncement, nip5_profile.public_key).identifier(repo_id),
+ nip5_profile.relays,
+ )
+ .expect("The relays is `RelayUrl`"),
+ ))
+}
+
+#[easy_ext::ext(NaddrOrSetVecExt)]
+impl Vec {
+ /// Converts this vector of [`NaddrOrSet`] into a flat vector of
+ /// [`Nip19Coordinates`] using the given sets.
+ pub fn flat_naddrs(self, sets: &[RepoRelaySet]) -> N34Result> {
+ self.into_iter()
+ .map(|n| n.get_naddrs(sets))
+ .try_fold(Vec::new(), |mut acc, item| {
+ acc.extend(item?);
+ Ok(acc)
+ })
+ }
+}
+
+#[easy_ext::ext(RelayOrSetVecExt)]
+impl Vec {
+ /// Converts this vector of [`RelayOrSet`] into a flat vector of
+ /// [`RelayUrl`] using the given sets.
+ pub fn flat_relays(self, sets: &[RepoRelaySet]) -> N34Result> {
+ self.into_iter()
+ .map(|n| n.get_relays(sets))
+ .try_fold(Vec::new(), |mut acc, item| {
+ acc.extend(item?);
+ Ok(acc)
+ })
+ }
+}
+
+
+#[easy_ext::ext(OptionNaddrOrSetVecExt)]
+impl Option> {
+ /// Converts this vector of [`NaddrOrSet`] into a flat vector of
+ /// [`Nip19Coordinates`] using the given sets.
+ pub fn flat_naddrs(&self, sets: &[RepoRelaySet]) -> N34Result