53 Commits

Author SHA1 Message Date
Awiteb
c1afa9a93d chore: Bump the version to v0.1.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 22:41:37 +00:00
Awiteb
92165de6fd chore: Improve the changelog
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 22:07:24 +00:00
Awiteb
c5085b3af7 chore: Update the bugs report location
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 22:07:24 +00:00
Awiteb
ecee54c9a3 chore: Update README.md
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 22:07:23 +00:00
Awiteb
55aa48c1fe chore: Add CONTRIBUTING.md, AUTHORS and DCO files
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
0cc656b780 chore: Update the repository url
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
8bff000b33 chore: Format rustfmt.toml
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
5c201b0cec chore(justfile): A new recipe to release n34
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
c608f2120b chore: Add taplo to the packages
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
60ad1fe5bb chore: Improve issue new command code
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
b3b7ce7a4b chore(repo-announce): Ensure that the event author is not a duplicate maintainer
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
a137a3f9de chore: Rename nostr::nips::nip22::Comment to CommentTarget
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
724e2707c6 deps: Bump nostr and nostr_sdk to 0.42.0
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:06 +00:00
Awiteb
9444fc1a8c feat: New reply command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:05 +00:00
Awiteb
a324ba2ff7 chore: Add comments helper functions
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:05 +00:00
Awiteb
56316d36d3 chore: Add events errors
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:05 +00:00
Awiteb
074245733e chore(nostr): A util to get the content from a string or editor
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:05 +00:00
Awiteb
661266c67b chore(nostr): A function to add user read relays to a vector
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:45:05 +00:00
Awiteb
a9b088d8a5 chore(NostrClient-add_relays): If the relays is empty do nothing
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-21 21:44:51 +00:00
Awiteb
1af731ef67 chore: Improve event fetchers functions
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-19 20:26:29 +00:00
Awiteb
4298ca5d2d chore: Use Coordinate instead of Nip19Coordinate in NostrClient::fetch_repo
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-19 20:26:29 +00:00
Awiteb
70ee4a59b2 perf: Connect to relays in a separate task
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 14:25:09 +00:00
Awiteb
bd086538fd deps: Add futures@0.3.31 to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 13:32:44 +00:00
Awiteb
54f1c7e0e6 feat: Add issue new command
Command to create a new issue on a repo

Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 13:03:09 +00:00
Awiteb
f1378a2eb7 chore: More errors for issue subcommand
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 13:02:04 +00:00
Awiteb
2627fe7980 chore: Function to returns the event author if found
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 13:01:20 +00:00
Awiteb
42508fc8b9 chore: Returns the relays that successfully received the event on NostrClient::send_event_to
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 13:01:05 +00:00
Awiteb
c69fbca2a0 chore(justfile): Add some fancy emojis
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 12:56:43 +00:00
Awiteb
552136400d chore: Improve the MSRV check
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 12:40:01 +00:00
Awiteb
ac7c1e6216 chore: Add MSRV
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 12:32:50 +00:00
Awiteb
5139f8ea11 chore: Improve logging
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 12:27:13 +00:00
Awiteb
6aac4b7a85 chore: Rename --labels flag of repo announce command to --label
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 12:25:06 +00:00
Awiteb
ce58f29022 deps: Add tempfile@3.19.1 to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-08 12:23:10 +00:00
Awiteb
d9fe376746 chore: Sort the relays then take 3 from them
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-07 20:20:36 +00:00
Awiteb
d9f8ce2418 chore: Util to add write relays to a vector
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-07 06:12:17 +00:00
Awiteb
577de41054 chore(repo-announce): Utils to creates the bech32
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-07 06:04:28 +00:00
Awiteb
3857753b7d chore: Log the event result inside send_event_to function
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-07 05:42:07 +00:00
Awiteb
93c8743dc7 chore: Add the relays for read and write
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-06 12:24:33 +00:00
Awiteb
c0a5e47793 feat(nip13): Support PoW
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-06 11:36:56 +00:00
Awiteb
a56acf5752 chore: Trim the strings in repo announce command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-06 11:30:27 +00:00
Awiteb
9b4dfb9374 chore: Returns Result public key in CliOptions::pubkey
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-06 11:22:46 +00:00
Awiteb
6fc59bfe59 chore: Send the repo announce to the author relays list
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-06 11:20:41 +00:00
Awiteb
b444aeba0a feat: Add repo announce command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-06 08:10:06 +00:00
Awiteb
273058fe3f chore: A method to send an event builder
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-05 19:56:41 +00:00
Awiteb
4dea4785b4 chore: A method to return the public key in CliOptions
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-05 19:56:41 +00:00
Awiteb
4314cebfbb chore: Take the ownership in the CommandRunner trait
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-05 19:56:41 +00:00
Awiteb
8a553f4de8 deps: Add convert_case@0.8.0 to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-05 19:56:41 +00:00
Awiteb
57434ac39b chore: Handle euc in the repo view command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-05 19:56:41 +00:00
Awiteb
99e46d7633 chore: Add a extension trait for Tags type
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-05 19:56:40 +00:00
Awiteb
fb77a8ca14 deps: Add easy_ext@1.0.2 to the dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-05 13:52:00 +00:00
Awiteb
d962732e52 feat: Setup the CLI and create repo view command
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-03 15:58:45 +00:00
Awiteb
654aee620e chore: Add .taplo.toml file
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-03 15:23:41 +00:00
Awiteb
d37c6964f7 deps: Add nedded dependencies
Signed-off-by: Awiteb <a@4rs.nl>
2025-05-03 15:22:46 +00:00
26 changed files with 3951 additions and 62 deletions

9
.taplo.toml Normal file
View File

@@ -0,0 +1,9 @@
[formatting]
align_entries = true
indent_tables = true
reorder_keys = false
[[rule]]
include = ["**/Cargo.toml"]
keys = ["dependencies"]
formatting.reorder_keys = true

1
AUTHORS Normal file
View File

@@ -0,0 +1 @@
Awiteb <a@4rs.nl> (nprofile1qqsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgpzfmhxue69uhkummnw3ezudrjwvhxumq3dg0ly)

View File

@@ -5,11 +5,32 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2025-05-21
### Added
- Setup the CLI and create `repo view` command - (Awiteb at [d962732](https://git.4rs.nl/awiteb/n34.git/commit/?id=d962732e5251b3af81b6d12327e258b49e3e603e))
- Add `repo announce` command - (Awiteb at [b444aeb](https://git.4rs.nl/awiteb/n34.git/commit/?id=b444aeba0ac6d19cb32715fb70c7dc1da297f8e9))
- Support PoW - (Awiteb at [c0a5e47](https://git.4rs.nl/awiteb/n34.git/commit/?id=c0a5e4779385024c8b65ae7626081a54ccbcb5b7))
- Add `issue new` command - (Awiteb at [54f1c7e](https://git.4rs.nl/awiteb/n34.git/commit/?id=54f1c7e0e68670926a99993651db6f4e15087250))
- New `reply` command - (Awiteb at [9444fc1](https://git.4rs.nl/awiteb/n34.git/commit/?id=9444fc1a8c75dcfb9f74be5e7ea7df6759a5ce6c))
### Dependencies
- Add nedded dependencies - (Awiteb at [d37c696](https://git.4rs.nl/awiteb/n34.git/commit/?id=d37c6964f7111097550a04d25768085d4c6694d6))
- Add `easy_ext@1.0.2` to the dependencies - (Awiteb at [fb77a8c](https://git.4rs.nl/awiteb/n34.git/commit/?id=fb77a8ca141e885f351ef671680dbc3f54229303))
- Add `convert_case@0.8.0` to the dependencies - (Awiteb at [8a553f4](https://git.4rs.nl/awiteb/n34.git/commit/?id=8a553f4de85891c40965a9100f2853034919b6c8))
- Add `tempfile@3.19.1` to the dependencies - (Awiteb at [ce58f29](https://git.4rs.nl/awiteb/n34.git/commit/?id=ce58f2902246637c8ebd8b66d14cbdf958c09543))
- Add `futures@0.3.31` to the dependencies - (Awiteb at [bd08653](https://git.4rs.nl/awiteb/n34.git/commit/?id=bd086538fdeed1b39d3d8a355fc453722ab04bc2))
- Bump `nostr` and `nostr_sdk` to `0.42.0` - (Awiteb at [724e270](https://git.4rs.nl/awiteb/n34.git/commit/?id=724e2707c69fc8ac16f05331970919439619346e))
## [0.0.0] - 2025-05-01
### Added
- Feat: Initialize the project
- Initialize the project - (Awiteb at [c3594c6](https://git.4rs.nl/awiteb/n34.git/commit/?id=c3594c64671f622386a6dab12cb97b9f44c1611e))
[0.1.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.1.0
[0.0.0]: https://git.4rs.nl/awiteb/n34.git/tag/?h=v0.0.0
<!-- generated by git-cliff -->

86
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,86 @@
# Contributing to `n34`
For basic information about the `n34` project, please read the
[README.md](README.md). The project is licensed under **GPL-3.0**, and by
contributing, your work will also be licensed under the same terms.
Before submitting changes, please read the [Developer Certificate of Origin](DCO).
All patches must include a `Signed-off-by: NAME <EMAIL>` line to acknowledge
your agreement with the DCO.
We welcome all contributions, whether it be bug reports, fixes, feature
submissions, feature requests, or improving documentation or testing. Enjoy
collaborating!
## Git Repository
The repository is hosted at <https://git.4rs.nl/awiteb/n34.git>, with `master`
as the active development branch.
## Nostr Repository Address
You can submit issues and patches via any
Nostr-compatible client using the address:
`naddr1qqpkuve5qgsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgrqsqqqaueq
yf8wumn8ghj7mn0wd68yt35wfejumnvqyxhwumn8ghj7mn0wvhxcmmvqy28wumn8ghj7mn0wd68ytn00
p68ytnyv4mqwuj6xc`
## Contribution Workflow
Before submitting changes, open an issue to discuss your proposed contribution.
Clearly indicate that you intend to work on it and wait for a maintainer's
response. If the issue remains open, you may proceed with submitting your patch.
### Reporting Issues
When opening an issue, include:
- Detailed steps to reproduce the problem
- Relevant error messages or logs (use the `-vvvv` flag for verbose output)
- Expected vs. actual behavior
Please label your issue appropriately (e.g., `bug`, `feature`, `question`) to
help categorize it.
### Your Patch
Ensure your patch submission tool notifies the maintainers and sends the patch
to their read relays, most tools handle this automatically.
#### Patch Guidelines
- Keep patches small: Focused changes are easier to review and merge.
- Use [Conventional Commits]: Start the patch subject with one of these types:
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation updates
- `refactor`: Code restructuring without behavioral changes
- `deprecate`: Marking code as deprecated
- `remove`: Removing deprecated code
- `security`: Security-related changes
- `perf`: Performance improvements
- `test`: Test additions or corrections
- For all other changes, use `chore`.
- Add `!` to the subject if your patch contains a breacking change, e.g.
`remove!: text` and `fix(reply)!: text`
- Run `just ci` before submitting your patch.
#### Code Style
When writing code, make sure to folow this:
- Using Rust's official formatting tool, `rustfmt`, to format your code.
- Writing clear and concise code with meaningful variable and function names.
- Adding comments to explain complex logic or algorithms.
#### Cover Letter Description
Your patch description should provide a clear and concise summary of the changes you
have made. It should also include any relevant context or background information
that will help the project maintainers understand the purpose of the changes.
Make sure to reference the issue[^1] that your patch is addressing, and note any breaking
changes that your patch introduces.
[^1]: When referencing, avoid URLs or `nevent` formats with relays. Instead, use only the note ID in `note1` bech32 format.
[Conventional Commits]: https://www.conventionalcommits.org/en/v1.0.0/

2073
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,28 @@
[package]
name = "n34"
description = "A CLI to interact with NIP-34 and other stuff related to codes in nostr"
version = "0.0.0"
edition = "2024"
license = "GPL-3.0-or-later"
authors = ["Awiteb <a@4rs.nl>"]
readme = "README.md"
repository = "https://git.4rs.nl/awiteb/n34"
keywords = ["nostr", "NIP-34", "CLI"]
categories = ["command-line-utilities"]
name = "n34"
description = "A CLI to interact with NIP-34 and other stuff related to codes in nostr"
version = "0.1.0"
edition = "2024"
license = "GPL-3.0-or-later"
authors = ["Awiteb <a@4rs.nl>"]
readme = "README.md"
repository = "https://git.4rs.nl/awiteb/n34.git"
keywords = ["nostr", "NIP-34", "CLI"]
categories = ["command-line-utilities"]
rust-version = "1.85.1"
[dependencies]
clap = { version = "4.5.37", features = ["derive"] }
clap-verbosity-flag = { version = "3.0.2", default-features = false, features = [
"tracing",
] }
convert_case = "0.8.0"
easy-ext = "1.0.2"
futures = "0.3.31"
nostr = { version = "0.42.0", features = ["parser"] }
nostr-sdk = "0.42.0"
tempfile = "3.19.1"
thiserror = "2.0.12"
tokio = { version = "1.44.2", features = ["macros", "rt-multi-thread"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"

34
DCO Normal file
View File

@@ -0,0 +1,34 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.

View File

@@ -1,9 +1,74 @@
!!! warning "Work In Progress"
This project is still work in progress and not completed yet.
# n34
A CLI to interact with NIP-34 and other stuff related to codes in nostr
## About
`n34` is a command-line interface (CLI) tool for sending and receiving Git
issues, patches, and comments over the Nostr protocol. It supports creating,
replying to, and managing issues and patches, making Git collaboration
decentralized and censorship-resistant.
The primary goal of `n34` is to implement [NIP-34] (`git` stuff), but its
flexible design allows for additional use cases beyond Git workflows. For more
details, see the following section.
## Features
- [X] Repository announcements
- [ ] Repository state announcements
- [ ] Patches
- [X] Issues
- [X] Replies
- [X] Status
- [X] Gossip Model ([NIP-65])
- [X] Proof of Work ([NIP-13])
- [X] `nostr:` URI scheme, in the issue/reply content ([NIP-21])
- [ ] Code Snippets ([NIP-C0])
- [ ] In device relays and repos bookmark
## Why Nostr?
Nostr is fundamentally different from traditional platforms because its not
an application or service, its a decentralized protocol. This means any tool or
app can integrate with it, enabling open, permissionless collaboration without
relying on centralized gatekeepers. Unlike proprietary systems, Nostr doesnt
require emails, passwords, or accounts. You interact directly through relays,
whether you self-host your own or use public ones, ensuring no single point of
failure or control.
What makes Nostr uniquely resilient is its design, the protocol itself is just
a set of rules, not a company or product that can disappear. Your Git issues,
patches, and comments persist as long as relays choose to store them, immune to
the whims of corporate shutdowns or policy changes. Nostr is infrastructure in
its purest form, an idea that outlives any temporary implementation. `n34` taps
into a future-proof foundation for decentralized collaboration.
### More about Nostr
- <https://nostr.com>
- <https://nostr.org>
- <https://nostr.how/en/what-is-nostr>
## Contributing
Contributions to `n34` are welcome! You can help by opening issues (such as bug
reports or feature requests) or submitting patches. **All contributions must be
submitted through Nostr**. For more details on the process, please refer to the
[CONTRIBUTING.md](CONTRIBUTING.md) file. Your support is greatly appreciated!
## License
n34 is licensed under the GPL-3.0 License. This means that you are free to use,
modify, and distribute the software under the terms of this license. Please
refer to the [LICENSE](LICENSE) file for more details.
[NIP-34]: https://github.com/nostr-protocol/nips/blob/master/34.md
[NIP-65]: https://github.com/nostr-protocol/nips/blob/master/65.md
[NIP-13]: https://github.com/nostr-protocol/nips/blob/master/13.md
[NIP-21]: https://github.com/nostr-protocol/nips/blob/master/21.md
[NIP-C0]: https://github.com/nostr-protocol/nips/blob/master/C0.md

View File

@@ -17,12 +17,13 @@ body = """
{% if version -%}
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else -%}
## [Unreleased]
## Unreleased
{% endif -%}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | upper_first | trim }}\
- {{ commit.message | split(pat="\n") | first | split(pat=":") | slice(start=1) | join(sep=":") | upper_first | trim }} - \
({{ commit.author.name}} at [{{ commit.id | truncate(length=7, end="") }}]($REPO/commit/?id={{ commit.id }}))\
{% endfor %}
{% endfor %}\n
"""
@@ -31,12 +32,7 @@ body = """
footer = """
{% for release in releases -%}
{% if release.version -%}
{% if release.previous.version -%}
[{{ release.version | trim_start_matches(pat="v") }}]: \
https://git.4rs.nl/awiteb/n34/compare/{{ release.previous.version }}..{{ release.version }}
{% endif -%}
{% else -%}
[unreleased]: https://git.4rs.nl/awiteb/n34/compare/{{ release.previous.version }}..HEAD
[{{ release.version | trim_start_matches(pat="v") }}]: https://git.4rs.nl/awiteb/n34.git/tag/?h={{ release.version }}
{% endif -%}
{% endfor %}
<!-- generated by git-cliff -->
@@ -44,6 +40,11 @@ footer = """
# Remove leading and trailing whitespaces from the changelog's body.
trim = true
postprocessors = [
# Replace the placeholder `<REPO>` with a URL.
{ pattern = '\$REPO', replace = "https://git.4rs.nl/awiteb/n34.git" },
]
[git]
# Parse commits according to the conventional commits specification.
# See https://www.conventionalcommits.org
@@ -54,14 +55,14 @@ filter_unconventional = false
# Assigns commits to groups.
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
commit_parsers = [
{ message = "^.*!:.*$", group = "Breaking Change" },
{ message = "^feat", group = "Added" },
{ message = "^deprecate", group = "Deprecated" },
{ message = "^remove", group = "Removed" },
{ message = "^deps", group = "Dependencies" },
{ message = "^refactor", group = "Refactor" },
{ message = "^fix", group = "Fixed" },
{ message = "^docs", group = "Documentation" },
{ message = '^.*!:.*$', group = "Breaking Change" },
{ message = '^feat', group = "Added" },
{ message = '^deprecate', group = "Deprecated" },
{ message = '^remove', group = "Removed" },
{ message = '^deps', group = "Dependencies" },
{ message = '^refactor', group = "Refactor" },
{ message = '^fix', group = "Fixed" },
{ message = '^docs', group = "Documentation" },
]
# Prevent commits that are breaking from being excluded by commit parsers.
filter_commits = false

View File

@@ -22,9 +22,10 @@
{
devShells.default = mkShell {
packages = [
just
cargo-msrv
git-cliff
just
taplo
];
nativeBuildInputs = [

View File

@@ -8,42 +8,69 @@
set quiet
set unstable
set shell := ["/usr/bin/env", "bash", "-c"]
set script-interpreter := ["/usr/bin/env", "bash", "-c"]
set script-interpreter := ["/usr/bin/env", "bash"]
JUST_EXECUTABLE := "just -u -f " + justfile()
header := "Available tasks:\n"
# Get the MSRV from the Cargo.toml
msrv := `cat Cargo.toml | grep "rust-version" | sed 's/.*"\(.*\)".*/\1/'`
tag_change_body := '''{% for group, commits in commits | group_by(attribute="group") %}
{{ group | upper_first }}
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | split(pat=":") | slice(start=1) | join(sep=":") | upper_first | trim }} - (by {{ commit.author.name}}){% endfor %}{% endfor %}'''
export TZ := "UTC"
_default:
@{{JUST_EXECUTABLE}} --list-heading "{{header}}" --list
# Run the CI
ci: && msrv
ci: && msrv _done_ci
echo "🔨 Building n34..."
cargo build -q
cargo fmt -- --check
cargo clippy -- -D warnings
echo "🔍 Checking code formatting..."
cargo fmt -q -- --check
taplo fmt --check --config ./.taplo.toml
echo "🧹 Running linter checks..."
cargo clippy -q -- -D warnings
taplo check --config ./.taplo.toml
# Check that the current MSRV is correct
msrv:
rustup toolchain install {{msrv}}
echo "Checking MSRV ({{msrv}})"
cargo +{{msrv}} check -q
echo "MSRV is correct"
echo "🔧 Verifying MSRV ({{msrv}})..."
rustup -q run --install {{msrv}} cargo check -q
echo "✅ MSRV verification passed"
_done_ci:
echo "🎉 CI pipeline completed successfully"
# Update the changelog
[script]
change-log:
changelog:
OLD_HASH=$(sha256sum CHANGELOG.md | head -c 64)
git-cliff > CHANGELOG.md
NEW_HASH=$(sha256sum CHANGELOG.md | head -c 64)
if [[ $OLD_HASH != $NEW_HASH ]]; then
TZ=UTC git add CHANGELOG.md
TZ=UTC git commit -m 'chore(changelog): Update the changelog'
git add CHANGELOG.md
git commit -m 'chore(changelog): Update the changelog'
echo 'The changes have been added to the changelog file and committed'
else
echo 'No changes have been added to the changelog'
fi
alias cl := change-log
# Releases a new version of n34. Requires a clean file tree with no uncommitted changes.
[script]
release version:
set -e
TAG_MSG=$(git-cliff --strip all --unreleased --body '{{ tag_change_body }}')
sed -i "s/^version\s*= \".*\"/version = \"{{ version }}\"/" ./Cargo.toml
taplo fmt --config ./.taplo.toml ./Cargo.toml
{{ JUST_EXECUTABLE }} ci
git-cliff -t "v{{ version }}" > CHANGELOG.md
git add .
git commit -m 'chore: Bump the version to `v{{ version }}`'
git tag -s -m "$TAG_MSG" "v{{ version }}"
git push origin master --tags
cargo publish

View File

@@ -1,22 +1,22 @@
unstable_features = true
style_edition = "2024"
style_edition = "2024"
blank_lines_upper_bound = 2
combine_control_expr = false
wrap_comments = true
condense_wildcard_suffixes = true
edition = "2024"
enum_discrim_align_threshold = 20
force_multiline_blocks = true
format_code_in_doc_comments = true
format_generated_files = false
format_macro_matchers = true
format_strings = true
imports_layout = "HorizontalVertical"
newline_style = "Unix"
normalize_comments = true
reorder_impl_items = true
group_imports = "StdExternalCrate"
blank_lines_upper_bound = 2
combine_control_expr = false
wrap_comments = true
condense_wildcard_suffixes = true
edition = "2024"
enum_discrim_align_threshold = 20
force_multiline_blocks = true
format_code_in_doc_comments = true
format_generated_files = false
format_macro_matchers = true
format_strings = true
imports_layout = "HorizontalVertical"
newline_style = "Unix"
normalize_comments = true
reorder_impl_items = true
group_imports = "StdExternalCrate"
single_line_let_else_max_width = 0
struct_field_align_threshold = 20
use_try_shorthand = true
struct_field_align_threshold = 20
use_try_shorthand = true

38
src/cli/issue/mod.rs Normal file
View File

@@ -0,0 +1,38 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
/// `issue new` subcommand
mod new;
use clap::Subcommand;
use self::new::NewArgs;
use super::{CliOptions, CommandRunner};
use crate::error::N34Result;
#[derive(Subcommand, Debug)]
pub enum IssueSubcommands {
/// Create a new repository issue
New(NewArgs),
}
impl CommandRunner for IssueSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
match self {
Self::New(args) => args.run(options).await,
}
}
}

121
src/cli/issue/new.rs Normal file
View File

@@ -0,0 +1,121 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
use clap::{ArgGroup, Args};
use nostr::{event::EventBuilder, nips::nip19::Nip19Coordinate};
use crate::{
cli::{CliOptions, CommandRunner, parsers},
error::N34Result,
nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils},
};
/// Arguments for the `issue new` command
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("issue-content")
.args(["content", "editor"])
.required(true)
),
group(
ArgGroup::new("issue-subject")
.args(["editor", "subject"])
)
)]
pub struct NewArgs {
/// Repository address
#[arg(short, long, value_parser = parsers::repo_naddr)]
naddr: Nip19Coordinate,
/// Markdown content for the issue. Cannot be used together with the
/// `--editor` flag.
#[arg(short, long)]
content: Option<String>,
/// Opens the user's default editor to write issue content. The first line
/// will be used as the issue subject.
#[arg(short, long)]
editor: bool,
/// The issue subject. Cannot be used together with the `--editor` flag.
#[arg(long)]
subject: Option<String>,
/// Labels for the issue. Can be specified as arguments (-l bug) or hashtags
/// in content (#bug).
#[arg(short, long)]
label: Vec<String>,
}
impl NewArgs {
/// Returns the subject and the content of the issue. (subject, content)
pub fn issue_content(&self) -> N34Result<(Option<String>, String)> {
if let Some(content) = self.content.as_ref() {
if let Some(subject) = self.subject.as_ref() {
return Ok((Some(subject.trim().to_owned()), content.trim().to_owned()));
}
return Ok((None, content.trim().to_owned()));
}
// If the `self.content` is `None` then the `self.editor` is `true`
let file_content = utils::read_editor(".md")?;
if file_content.contains('\n') {
Ok(file_content
.split_once('\n')
.map(|(s, c)| (Some(s.trim().to_owned()), c.trim().to_owned()))
.expect("There is a new line"))
} else {
tracing::info!("File content contains only issue body without a subject line");
Ok((None, file_content))
}
}
}
impl CommandRunner for NewArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let client = NostrClient::init(&options).await;
let user_pubk = options.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).await?;
let mut write_relays =
utils::add_write_relays(options.relays.clone(), relays_list.as_ref());
client.add_relays(&options.relays).await;
client.add_relays(&self.naddr.relays).await;
write_relays.extend(client.fetch_repo(&self.naddr.coordinate).await?.relays);
let (subject, content) = self.issue_content()?;
let content_details = client.parse_content(&content).await;
write_relays.extend(content_details.write_relays.clone());
let event = EventBuilder::new_git_issue(
self.naddr.coordinate.clone(),
content,
subject,
self.label,
)?
.tags(content_details.into_tags())
.pow(options.pow)
.build(user_pubk);
let event_id = event.id.expect("There is an id");
tracing::trace!(relays = ?write_relays, "Write relays list");
let success = client
.send_event_to(event, relays_list.as_ref(), &write_relays)
.await?;
let nevent = utils::new_nevent(event_id, &success)?;
println!("Issue created: {nevent}");
Ok(())
}
}

146
src/cli/mod.rs Normal file
View File

@@ -0,0 +1,146 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
/// `issue` subcommands
mod issue;
/// CLI arguments parsers
pub mod parsers;
/// 'reply` command
mod reply;
/// `repo` subcommands
mod repo;
/// CLI traits
mod traits;
use std::fmt;
use clap::{ArgGroup, Args, Parser};
use clap_verbosity_flag::Verbosity;
use nostr::key::Keys;
use nostr::{PublicKey, RelayUrl, SecretKey};
use self::issue::IssueSubcommands;
use self::reply::ReplyArgs;
use self::repo::RepoSubcommands;
use self::traits::CommandRunner;
use crate::error::N34Result;
/// Header message, used in the help message
const HEADER: &str = r#"Copyright (C) 2025 Awiteb <a@4rs.nl>
License GNU GPL-3.0-or-later <https://gnu.org/licenses/gpl-3.0.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Git repository: https://git.4rs.nl/awiteb/n34.git"#;
/// Footer message, used in the help message
const FOOTER: &str = r#"Please report bugs to <naddr1qqpkuve5qgsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgrqsqqqauesksc39>."#;
/// The command-line interface options
#[derive(Args, Clone)]
#[clap(
group(
ArgGroup::new("auth")
.args(&["secret_key"])
.required(true)
)
)]
pub struct CliOptions {
/// Your Nostr secret key
#[arg(short, long)]
pub secret_key: Option<SecretKey>,
/// Where your relays list. And repository relays if not included in naddr
#[arg(short, long, required = true)]
pub relays: Vec<RelayUrl>,
/// Proof of Work difficulty when creatring events
#[arg(long, default_value_t = 0)]
pub pow: u8,
}
#[derive(Parser, Debug)]
#[command(about, version, before_long_help = HEADER, after_long_help = FOOTER)]
/// A command-line interface for interacting with NIP-34 and other Nostr
/// code-related stuff.
pub struct Cli {
#[command(flatten)]
pub options: CliOptions,
/// Controls the verbosity level of output
#[command(flatten)]
pub verbosity: Verbosity,
/// The subcommand to execute
#[command(subcommand)]
pub command: Commands,
}
/// N34 commands
#[derive(Parser, Debug)]
pub enum Commands {
/// Manage repositories
Repo {
#[command(subcommand)]
subcommands: RepoSubcommands,
},
/// Manage issues
Issue {
#[command(subcommand)]
subcommands: IssueSubcommands,
},
// /// Manage patches
// Patch {
// #[command(subcommand)]
// subcommands: PatchSubcommands,
// },
/// Reply to issues and patches.
Reply(ReplyArgs),
}
impl Cli {
/// Executes the command
pub async fn run(self) -> N34Result<()> {
self.command.run(self.options).await
}
}
impl CommandRunner for Commands {
async fn run(self, options: CliOptions) -> N34Result<()> {
tracing::trace!("Options: {options:#?}");
tracing::trace!("Handling: {self:#?}");
match self {
Self::Repo { subcommands } => subcommands.run(options).await,
Self::Issue { subcommands } => subcommands.run(options).await,
Commands::Reply(args) => args.run(options).await,
}
}
}
impl CliOptions {
/// Gets the public key of the user.
pub async fn pubkey(&self) -> N34Result<PublicKey> {
if let Some(sk) = &self.secret_key {
return Ok(Keys::new(sk.clone()).public_key());
}
unreachable!("There is no other method until now")
}
}
impl fmt::Debug for CliOptions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CliOptions")
.field("secret_key", &self.secret_key.as_ref().map(|_| "*******"))
.field("relays", &self.relays)
.finish()
}
}

39
src/cli/parsers.rs Normal file
View File

@@ -0,0 +1,39 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
use nostr::{
Kind,
nips::nip19::{FromBech32, Nip19Coordinate},
};
/// Parses a Nostr naddr string into a Git repository announcement coordinate.
///
/// # Errors
/// Returns an error if:
/// - The bech32 decoding fails
/// - The naddr doesn't represent a Git repository announcement (kind != 30617)
pub fn repo_naddr(naddr: &str) -> Result<Nip19Coordinate, String> {
let naddr = Nip19Coordinate::from_bech32(naddr).map_err(|err| err.to_string())?;
if naddr.kind != Kind::GitRepoAnnouncement {
return Err("The naddr is not repo announcement address".to_owned());
}
if naddr.relays.is_empty() {
tracing::warn!("The repository naddr does not contain any relay hints");
}
Ok(naddr)
}

178
src/cli/reply.rs Normal file
View File

@@ -0,0 +1,178 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
use std::str::FromStr;
use clap::{ArgGroup, Args};
use nostr::{
event::{EventBuilder, EventId, Kind, Tag},
filter::Filter,
nips::nip19::{self, FromBech32, Nip19Coordinate},
types::RelayUrl,
};
use super::{CliOptions, CommandRunner, parsers};
use crate::{
error::{N34Error, N34Result},
nostr_utils::{NostrClient, utils},
};
/// Parses and represents a Nostr `nevent1` or `note1`.
#[derive(Debug, Clone)]
struct NostrEvent {
/// Unique identifier for the event.
event_id: EventId,
/// List of relay URLs associated with the event. Empty if parsing a
/// `note1`.
relays: Vec<RelayUrl>,
}
impl NostrEvent {
/// Create a new [`NostrEvent`] instance
fn new(event_id: EventId, relays: Vec<RelayUrl>) -> Self {
Self { event_id, relays }
}
}
impl FromStr for NostrEvent {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.trim().starts_with("nevent1") {
let event = nip19::Nip19Event::from_bech32(s).map_err(|e| e.to_string())?;
Ok(Self::new(event.event_id, event.relays))
} else if s.trim().starts_with("note1") {
Ok(Self::new(
EventId::from_bech32(s).map_err(|e| e.to_string())?,
Vec::new(),
))
} else {
Err("Invalid event id, must starts with `note1` or `nevent1`".to_owned())
}
}
}
/// Arguments for the `reply` command
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("comment-content")
.args(["comment", "editor"])
.required(true)
)
)]
pub struct ReplyArgs {
/// The issue, patch, or comment to reply to
#[arg(long)]
to: NostrEvent,
/// Repository address
#[arg(short, long, value_parser = parsers::repo_naddr)]
naddr: Option<Nip19Coordinate>,
/// The comment (cannot be used with --editor)
#[arg(short, long)]
comment: Option<String>,
/// Open editor to write comment (cannot be used with --content)
#[arg(short, long)]
editor: bool,
}
impl CommandRunner for ReplyArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let client = NostrClient::init(&options).await;
let user_pubk = options.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).await?;
let mut write_relays =
utils::add_write_relays(options.relays.clone(), relays_list.as_ref());
client.add_relays(&options.relays).await;
client.add_relays(&self.to.relays).await;
if let Some(ref naddr) = self.naddr {
client.add_relays(&naddr.relays).await;
}
let reply_to = client
.fetch_event(Filter::new().id(self.to.event_id))
.await?
.ok_or(N34Error::EventNotFound)?;
let root = client.find_root(reply_to.clone()).await?;
let repo_naddr = if let Some(naddr) = self.naddr {
naddr.coordinate
} else if let Some(ref root_event) = root {
root_event
.tags
.coordinates()
.find(|c| c.kind == Kind::GitRepoAnnouncement)
.ok_or_else(|| {
N34Error::InvalidEvent(
"The Git issue/patch does not specify a target repository".to_owned(),
)
})?
.clone()
} else {
return Err(N34Error::NotFoundRepo);
};
let repo = client.fetch_repo(&repo_naddr).await?;
write_relays.extend(repo.relays.clone());
write_relays = client
.read_relays_from_user(write_relays, reply_to.pubkey)
.await;
if let Some(root_event) = &root {
write_relays = client
.read_relays_from_user(write_relays, root_event.pubkey)
.await;
}
let content = utils::get_content(self.comment.as_ref(), ".txt")?;
let content_details = client.parse_content(&content).await;
write_relays.extend(content_details.write_relays.clone());
let event = EventBuilder::comment(
content,
&reply_to,
root.as_ref(),
repo.relays.first().cloned(),
)
.tag(Tag::public_key(repo_naddr.public_key))
.tags(content_details.into_tags())
.pow(options.pow)
.build(user_pubk);
let event_id = event.id.expect("There is an id");
let author_read_relays = utils::add_read_relays(Vec::new(), relays_list.as_ref());
tracing::trace!(relays = ?write_relays, "Write relays list");
let (success, ..) = futures::join!(
client.send_event_to(event, relays_list.as_ref(), &write_relays),
client.broadcast(&reply_to, &author_read_relays),
async {
if let Some(root_event) = root {
let _ = client.broadcast(&root_event, &author_read_relays).await;
}
},
);
let nevent = utils::new_nevent(event_id, &success?)?;
println!("Comment created: {nevent}");
Ok(())
}
}

88
src/cli/repo/announce.rs Normal file
View File

@@ -0,0 +1,88 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
use clap::Args;
use nostr::{event::EventBuilder, key::PublicKey, types::Url};
use crate::{
cli::{CliOptions, CommandRunner},
error::N34Result,
nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils},
};
/// Arguments for the `repo announce` command
#[derive(Args, Debug)]
pub struct AnnounceArgs {
/// Unique identifier for the repository in kebab-case.
#[arg(long = "id")]
repo_id: String,
/// A name for the repository.
#[arg(short, long)]
name: Option<String>,
/// A description for the repository.
#[arg(short, long)]
description: Option<String>,
/// Webpage URLs for the repository (if provided by the git server).
#[arg(short, long)]
web: Vec<Url>,
/// URLs for cloning the repository.
#[arg(short, long)]
clone: Vec<Url>,
/// Additional maintainers of the repository (besides yourself).
#[arg(short, long)]
maintainers: Vec<PublicKey>,
/// Labels to categorize the repository. Can be specified multiple times.
#[arg(short, long)]
label: Vec<String>,
}
impl CommandRunner for AnnounceArgs {
async fn run(mut self, options: CliOptions) -> N34Result<()> {
let client = NostrClient::init(&options).await;
let user_pubk = options.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).await?;
let write_relays = utils::add_write_relays(options.relays.clone(), relays_list.as_ref());
if !self.maintainers.contains(&user_pubk) {
self.maintainers.insert(0, user_pubk);
}
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(),
self.maintainers,
self.label.into_iter().map(utils::str_trim).collect(),
)?
.pow(options.pow)
.build(user_pubk);
let nevent = utils::new_nevent(event.id.expect("There is an id"), &write_relays)?;
let naddr = utils::repo_naddr(user_pubk, &options.relays)?;
client
.send_event_to(event, relays_list.as_ref(), &write_relays)
.await?;
println!("Event: {nevent}",);
println!("Repo Address: {naddr}",);
Ok(())
}
}

46
src/cli/repo/mod.rs Normal file
View File

@@ -0,0 +1,46 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
/// `repo announce` subcommand
mod announce;
/// `repo view` subcommand
mod view;
use clap::Subcommand;
use self::announce::AnnounceArgs;
use self::view::ViewArgs;
use super::{CliOptions, CommandRunner};
use crate::error::N34Result;
#[derive(Subcommand, Debug)]
pub enum RepoSubcommands {
/// View details of a nostr git repository
View(ViewArgs),
/// Publish information about a git repository to Nostr for collaboration
/// and feedback. Can also be used to update an existing repository's
/// details.
Announce(AnnounceArgs),
}
impl CommandRunner for RepoSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
match self {
Self::View(args) => args.run(options).await,
Self::Announce(args) => args.run(options).await,
}
}
}

86
src/cli/repo/view.rs Normal file
View File

@@ -0,0 +1,86 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
use std::fmt;
use clap::Args;
use nostr::nips::nip19::Nip19Coordinate;
use crate::{
cli::{CliOptions, CommandRunner, parsers},
error::N34Result,
nostr_utils::NostrClient,
};
/// Arguments for the `repo view` command
#[derive(Args, Debug)]
pub struct ViewArgs {
/// Nostr repository address
#[arg(short, long, value_parser = parsers::repo_naddr)]
naddr: Nip19Coordinate,
}
impl CommandRunner for ViewArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let client = NostrClient::init(&options).await;
if !self.naddr.relays.is_empty() {
client.add_relays(&self.naddr.relays).await;
}
let repo = client.fetch_repo(&self.naddr.coordinate).await?;
let mut msg = format!("ID: {}", repo.id);
if let Some(name) = repo.name {
msg.push_str(&format!("\nName: {name}"));
}
if let Some(desc) = repo.description {
msg.push_str(&format!("\nDescription: {desc}"));
}
if !repo.web.is_empty() {
msg.push_str(&format!("\nWebpages:\n{}", format_list(repo.web)));
}
if !repo.clone.is_empty() {
msg.push_str(&format!("\nClone urls:\n{}", format_list(repo.clone)));
}
if !repo.relays.is_empty() {
msg.push_str(&format!("\nRelays:\n{}", format_list(repo.relays)));
}
if let Some(euc) = repo.euc {
msg.push_str(&format!("\nEarliest unique commit: {euc}"));
}
if !repo.maintainers.is_empty() {
msg.push_str(&format!(
"\nMaintainers:\n{}",
format_list(repo.maintainers)
));
}
println!("{msg}");
Ok(())
}
}
/// Format a vector to print it
fn format_list<T>(vector: Vec<T>) -> String
where
T: fmt::Display,
{
vector
.into_iter()
.map(|t| format!(" - {t}"))
.collect::<Vec<String>>()
.join("\n")
}

24
src/cli/traits.rs Normal file
View File

@@ -0,0 +1,24 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
use super::CliOptions;
use crate::error::N34Result;
/// A trait defining the interface for command runners in the CLI.
pub trait CommandRunner {
/// Executes the command and returns a Result indicating success or failure.
async fn run(self, options: CliOptions) -> N34Result<()>;
}

62
src/error.rs Normal file
View File

@@ -0,0 +1,62 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
use std::process::ExitCode;
use nostr::event::builder::Error as EventBuilderError;
use nostr_sdk::client::Error as ClientError;
pub type N34Result<T> = Result<T, N34Error>;
/// N34 errors
#[derive(Debug, thiserror::Error)]
pub enum N34Error {
#[error("IO: {0}")]
Io(#[from] std::io::Error),
#[error("No editor specified in the `EDITOR` environment variable")]
EditorNotFound,
#[error("The file you edited is empty. Please save your changes before exiting the editor.")]
EmptyEditorFile,
#[error("Client Error: {0}")]
Client(#[from] ClientError),
#[error("Unable to locate the repository. The repository may not exists in the given relays")]
NotFoundRepo,
#[error("Failed building an event: {0}")]
EventBuilder(#[from] EventBuilderError),
#[error("Invalid repository id, it can't be empty and must be kebab-case")]
InvalidRepoId,
#[error("Invalid event: {0}")]
InvalidEvent(String),
#[error("Bech32 error: {0}")]
Bech32(#[from] nostr::nips::nip19::Error),
#[error("Event error: {0}")]
Event(#[from] nostr::event::Error),
#[error("Event not found in the specified relays")]
EventNotFound,
#[error(
"Can't reply to this event. Only Git issues, patches, and their comments can be replied \
to."
)]
CanNotReplyToEvent,
}
impl N34Error {
/// Returns the exit code associated with this error
pub fn exit_code(&self) -> ExitCode {
// TODO: More specific exit code
ExitCode::FAILURE
}
}

View File

@@ -14,6 +14,60 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
fn main() {
println!("Hello, world!");
/// Command line interface module
mod cli;
/// N34 errors
mod error;
/// Nostr utils module
mod nostr_utils;
use std::{
process::ExitCode,
sync::atomic::{AtomicBool, Ordering},
};
use clap::Parser;
use clap_verbosity_flag::Verbosity;
use tracing::Level;
use tracing_subscriber::{Layer, filter, layer::SubscriberExt};
use self::cli::Cli;
/// Whether the editor is currently open. Prevents logging while the editor is
/// open.
static EDITOR_OPEN: AtomicBool = AtomicBool::new(false);
/// Configures the logging level based on the provided verbosity.
///
/// When verbosity is set to TRACE, includes file and line numbers in logs.
fn set_log_level(verbosity: Verbosity) {
let is_trace = verbosity
.tracing_level()
.is_some_and(|l| l == tracing::Level::TRACE);
let logs_filter = filter::dynamic_filter_fn(move |m, _| {
// Disable all logs while editor is open
verbosity.tracing_level().unwrap_or(Level::ERROR) >= *m.level()
&& !EDITOR_OPEN.load(Ordering::Relaxed)
});
let logs_layer = tracing_subscriber::fmt::layer()
.with_file(is_trace)
.with_line_number(is_trace)
.without_time();
let subscriber = tracing_subscriber::registry().with(logs_layer.with_filter(logs_filter));
tracing::subscriber::set_global_default(subscriber).ok();
}
#[tokio::main]
async fn main() -> ExitCode {
let cli = Cli::parse();
set_log_level(cli.verbosity);
if let Err(err) = cli.run().await {
tracing::error!("{err}");
return err.exit_code();
}
ExitCode::SUCCESS
}

321
src/nostr_utils/mod.rs Normal file
View File

@@ -0,0 +1,321 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
/// Extension traits for nostr types.
pub mod traits;
/// Utility functions for nostr.
pub mod utils;
use std::{collections::HashSet, time::Duration};
use futures::future;
use nostr::{
event::{Event, EventId, Kind, Tag, TagStandard, Tags, UnsignedEvent},
filter::Filter,
key::{Keys, PublicKey},
nips::{nip01::Coordinate, nip22, nip34::GitRepositoryAnnouncement},
parser::NostrParser,
types::RelayUrl,
};
use nostr_sdk::Client;
use traits::TokenUtils;
use crate::{
cli::CliOptions,
error::{N34Error, N34Result},
};
/// Timeout duration for the clinet.
const CLIENT_TIMEOUT: Duration = Duration::from_millis(1500);
/// Parsed content details
pub struct ContentDetails {
/// Public keys of users mentioned in the content.
pub p_tagged: HashSet<PublicKey>,
/// Event IDs and optional relay URLs for quoted events.
pub quotes: HashSet<(EventId, Option<RelayUrl>)>,
/// Hashtags found in the content.
pub hashtags: HashSet<String>,
/// Relays where mentioned users and quoted authors are read.
pub write_relays: HashSet<RelayUrl>,
}
/// A client for interacting with the Nostr relays
pub struct NostrClient {
/// The underlying Nostr client implementation
client: Client,
}
impl ContentDetails {
/// Create a new [`ContentDetails`] instance
pub fn new(
users: impl IntoIterator<Item = PublicKey>,
quotes: impl IntoIterator<Item = (EventId, Option<RelayUrl>)>,
hashtags: impl IntoIterator<Item = String>,
write_relays: impl IntoIterator<Item = RelayUrl>,
) -> Self {
Self {
p_tagged: HashSet::from_iter(users),
quotes: HashSet::from_iter(quotes),
hashtags: HashSet::from_iter(hashtags),
write_relays: HashSet::from_iter(write_relays),
}
}
/// Converts the instance into a list of tags including hashtags, p-tagged
/// users, and quoted events.
pub fn into_tags(self) -> Tags {
let mut tags = Tags::new();
tags.extend(self.hashtags.into_iter().map(Tag::hashtag));
tags.extend(self.p_tagged.into_iter().map(Tag::public_key));
tags.extend(self.quotes.into_iter().map(|(event_id, relay_url)| {
Tag::from_standardized(TagStandard::Quote {
event_id,
relay_url,
public_key: None,
})
}));
tags
}
}
impl NostrClient {
/// Creates a new [`NostrClient`] with the given client and options.
const fn new(client: Client) -> Self {
Self { client }
}
/// Initializes a new [`NostrClient`] instance and connects to the specified
/// relays.
pub async fn init(options: &CliOptions) -> Self {
let client = Self::new(
Client::builder()
.signer(Keys::new(
options
.secret_key
.as_ref()
.expect("This the only method for now")
.clone(),
))
.build(),
);
client.add_relays(&options.relays).await;
client
}
/// Add relays and connect to them
pub async fn add_relays(&self, relays: &[RelayUrl]) {
if relays.is_empty() {
return;
}
let mut tasks = Vec::new();
for relay in relays {
let relay = relay.clone();
let client = self.client.clone();
tasks.push(tokio::spawn(async move {
client
.add_relay(&relay)
.await
.expect("It's a valid relay url");
if let Err(err) = client.try_connect_relay(&relay, CLIENT_TIMEOUT).await {
tracing::error!("Failed to connect to relay '{relay}': {err}");
}
}));
}
future::join_all(tasks).await;
}
/// Add a relay hint and connect to it
pub async fn add_relay_hint(&self, hint: Option<RelayUrl>) {
if let Some(relay) = hint {
self.add_relays(&[relay]).await
}
}
/// broadcast an event to the given relays
pub async fn broadcast(&self, event: &Event, relays: &[RelayUrl]) -> N34Result<()> {
self.client.send_event_to(relays, event).await?;
Ok(())
}
/// Broadcasts an unsigned event to given relays, optionally broadcast the
/// relays list event. Returns URLs of relays that successfully received
/// the event.
pub async fn send_event_to(
&self,
mut event: UnsignedEvent,
relays_list: Option<&Event>,
relays: &[RelayUrl],
) -> N34Result<Vec<RelayUrl>> {
event.ensure_id();
self.add_relays(relays).await;
let event_id = event.id.expect("It's there");
if let Some(event) = relays_list {
let _ = self.client.send_event_to(relays, event).await;
}
let result = self
.client
.send_event_to(relays, &event.sign(&self.client.signer().await?).await?)
.await?;
for relay in &result.success {
tracing::info!(event_id = %event_id, relay = %relay, "Event sent successfully");
}
for (relay, reason) in &result.failed {
tracing::warn!(event_id = %event_id, relay = %relay, reason = %reason, "Failed to send event");
}
Ok(result.success.into_iter().collect())
}
/// Fetches the first event matching the given filter, or None if no event
/// is found.
pub async fn fetch_event(&self, filter: Filter) -> N34Result<Option<Event>> {
Ok(self
.client
.fetch_events(filter, CLIENT_TIMEOUT)
.await?
.first_owned())
}
/// Try to fetch a repository and returns it
pub async fn fetch_repo(
&self,
repo_naddr: &Coordinate,
) -> N34Result<GitRepositoryAnnouncement> {
let filter = Filter::new()
.author(repo_naddr.public_key)
.kind(Kind::GitRepoAnnouncement)
.identifier(&repo_naddr.identifier);
self.fetch_event(filter)
.await?
.map(|e| utils::event_into_repo(e, &repo_naddr.identifier))
.ok_or(N34Error::NotFoundRepo)
}
/// Finds the root issue or patch for a given event. If the event is already
/// a root (issue/patch), returns it directly. For comments, follows
/// parent/root references until finding the root or failing. Returns
/// None if no root can be found.
pub async fn find_root(&self, mut event: Event) -> N34Result<Option<Event>> {
if !matches!(event.kind, Kind::GitIssue | Kind::GitPatch | Kind::Comment) {
return Err(N34Error::CanNotReplyToEvent);
}
loop {
if matches!(event.kind, Kind::GitIssue | Kind::GitPatch) {
return Ok(Some(event));
}
if let Some(nip22::CommentTarget::Event { id, relay_hint, .. }) =
nip22::extract_root(&event)
{
self.add_relay_hint(relay_hint.cloned()).await;
let root_event = self.fetch_event(Filter::new().id(*id)).await?;
if let Some(ref root_event) = root_event {
if !matches!(root_event.kind, Kind::GitIssue | Kind::GitPatch) {
return Err(N34Error::CanNotReplyToEvent);
}
}
return Ok(root_event);
} else if let Some(nip22::CommentTarget::Event { id, relay_hint, .. }) =
nip22::extract_parent(&event)
{
self.add_relay_hint(relay_hint.cloned()).await;
if let Ok(Some(parent_event)) = self.fetch_event(Filter::new().id(*id)).await {
event = parent_event;
continue;
}
}
// Break if: no root/parent tags found, parent/root event fetch failed
break;
}
Ok(None)
}
/// Fetches the relay list (kind 10002) for the given user. Returns None if
/// no relays are found.
pub async fn user_relays_list(&self, user: PublicKey) -> N34Result<Option<Event>> {
self.fetch_event(Filter::new().author(user).kind(Kind::RelayList))
.await
}
/// Gets the author of the specified event, if found.
pub async fn event_author(&self, event_id: EventId) -> N34Result<Option<PublicKey>> {
Ok(self
.fetch_event(Filter::new().id(event_id))
.await?
.map(|e| e.pubkey))
}
/// Adds read relays from the user to the given vector of relays.
pub async fn read_relays_from_user(
&self,
vector: Vec<RelayUrl>,
user: PublicKey,
) -> Vec<RelayUrl> {
utils::add_read_relays(
vector,
self.user_relays_list(user).await.ok().flatten().as_ref(),
)
}
/// Parse the given content and returns the details that inside it
pub async fn parse_content(&self, content: &str) -> ContentDetails {
let mut write_relays = Vec::new();
let tokens = NostrParser::new().parse(content).collect::<Vec<_>>();
let mut p_tagged_users = tokens
.iter()
.filter_map(TokenUtils::extract_public_key)
.collect::<Vec<_>>();
let quotes = tokens
.iter()
.filter_map(TokenUtils::extract_event_id)
.collect::<Vec<_>>();
let hashtags = tokens
.iter()
.filter_map(TokenUtils::extract_hashtag)
.collect::<Vec<_>>();
for (user, relays) in &p_tagged_users {
self.add_relays(relays).await;
write_relays = self.read_relays_from_user(write_relays, *user).await
}
for (event_id, relays) in &quotes {
self.add_relays(relays).await;
// Add the event author to the p-tagged users
if let Ok(Some(author)) = self.event_author(*event_id).await {
p_tagged_users.push((author, Vec::new()));
write_relays = self.read_relays_from_user(write_relays, author).await;
}
}
ContentDetails::new(
p_tagged_users.into_iter().map(|(p, _)| p),
quotes.into_iter().map(|(e, r)| (e, r.first().cloned())),
hashtags,
write_relays,
)
}
}

158
src/nostr_utils/traits.rs Normal file
View File

@@ -0,0 +1,158 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
use convert_case::{Case, Casing};
use nostr::{
event::{EventBuilder, EventId, Tag, TagKind, TagStandard, Tags},
key::PublicKey,
nips::{
nip01::Coordinate,
nip21::Nip21,
nip34::{GitIssue, GitRepositoryAnnouncement},
},
parser::Token,
types::{RelayUrl, Url},
};
use crate::error::{N34Error, N34Result};
/// A trait to add helper instance function to [`Tags`] type
#[easy_ext::ext(TagsExt)]
impl Tags {
/// Search for the given tag and map it value to a function
pub fn map_tag<T>(&self, kind: TagKind, f: impl FnOnce(&TagStandard) -> T) -> Option<T> {
self.find_standardized(kind).map(f)
}
/// Search for the given tag and map it value to a function. If the tag not
/// found return the default `T`
pub fn dmap_tag<T>(&self, kind: TagKind, f: impl FnOnce(&TagStandard) -> T) -> T
where
T: Default,
{
self.map_tag(kind, f).unwrap_or_default()
}
/// Finds the first standard tag of the given kind with the specified
/// marker, then applies the function to the tag and returns the result.
pub fn map_marker<T>(
&self,
kind: TagKind,
marker: &str,
f: impl FnOnce(&TagStandard) -> T,
) -> Option<T> {
self.filter_standardized(kind)
.find(|t| (*t).clone().to_vec().last().is_some_and(|m| m == marker))
.map(f)
}
}
/// Trait for building [`GitRepositoryAnnouncement`] events
#[easy_ext::ext(NewGitRepositoryAnnouncement)]
impl EventBuilder {
/// Creates a new [`GitRepositoryAnnouncement`] event builder with the given
/// repository details.
#[allow(clippy::too_many_arguments)]
pub fn new_git_repo(
repo_id: String,
name: Option<String>,
description: Option<String>,
web: Vec<Url>,
clone: Vec<Url>,
relays: Vec<RelayUrl>,
maintainers: Vec<PublicKey>,
labels: Vec<String>,
) -> N34Result<EventBuilder> {
let repo_id = repo_id.trim();
if repo_id.is_empty() || repo_id != repo_id.to_case(Case::Kebab) {
return Err(N34Error::InvalidRepoId);
}
Ok(
EventBuilder::git_repository_announcement(GitRepositoryAnnouncement {
id: repo_id.to_owned(),
name,
description,
web,
clone,
relays,
euc: None,
maintainers,
})?
.tags(labels.into_iter().map(Tag::hashtag)),
)
}
/// Creates a new [`GitIssue`] event builder with the given
/// issue details.
pub fn new_git_issue(
repository: Coordinate,
content: String,
subject: Option<String>,
labels: Vec<String>,
) -> N34Result<EventBuilder> {
EventBuilder::git_issue(GitIssue {
repository,
content,
subject,
labels: labels.into_iter().map(|l| l.trim().to_owned()).collect(),
})
.map_err(N34Error::from)
}
}
/// Helper functions for [`Token`] type
#[easy_ext::ext(TokenUtils)]
impl Token<'_> {
/// Returns `Some((public_key, relays))` from the givin token if it's npub1
/// or nprofile1
pub fn extract_public_key(&self) -> Option<(PublicKey, Vec<RelayUrl>)> {
match self {
Token::Nostr(nip21) => {
match nip21 {
Nip21::Pubkey(pkey) => Some((*pkey, Vec::new())),
Nip21::Profile(profile) => Some((profile.public_key, profile.relays.clone())),
_ => None,
}
}
_ => None,
}
}
/// Returns `Some((note_id, relays))` from the givin token if it's note1 or
/// nevent1
pub fn extract_event_id(&self) -> Option<(EventId, Vec<RelayUrl>)> {
match self {
Token::Nostr(nip21) => {
match nip21 {
Nip21::EventId(event_id) => Some((*event_id, Vec::new())),
Nip21::Event(event) => Some((event.event_id, event.relays.clone())),
_ => None,
}
}
_ => None,
}
}
/// Returns `Some(hashtag)` from the givin token if it's a hashtag
pub fn extract_hashtag(&self) -> Option<String> {
match self {
Token::Hashtag(tag) => Some(tag.trim().to_owned()),
_ => None,
}
}
}

197
src/nostr_utils/utils.rs Normal file
View File

@@ -0,0 +1,197 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
use std::{fmt, fs, str::FromStr, sync::atomic::Ordering};
use nostr::{
event::{Event, EventId, Kind, TagKind, TagStandard},
filter::{Alphabet, SingleLetterTag},
key::PublicKey,
nips::{
nip01::Coordinate,
nip19::{Nip19Coordinate, Nip19Event, ToBech32},
nip34::GitRepositoryAnnouncement,
nip65::{self, RelayMetadata},
},
types::RelayUrl,
};
use super::traits::TagsExt;
use crate::error::{N34Error, N34Result};
/// Returns the value of the given tag
fn tag_value(tag: &TagStandard) -> String {
tag.clone().to_vec().remove(1)
}
/// Parses the tag value into type `T` if possible.
fn parse_value<T: FromStr>(tag: &TagStandard) -> Option<T> {
tag_value(tag).parse().ok()
}
/// Gets all values from the tag. If any value fails to parse, returns an empty
/// vector.
fn tag_values<T>(tag: &TagStandard) -> Vec<T>
where
T: FromStr + fmt::Debug,
<T as FromStr>::Err: fmt::Debug,
{
tag.clone()
.to_vec()
.into_iter()
.skip(1)
.map(|t| {
let result = T::from_str(t.as_str());
tracing::trace!("Parsing `{t}` result: `{result:?}`");
result
})
.collect::<Result<_, _>>()
.unwrap_or_default()
}
/// Convert [`Event`] to [`GitRepositoryAnnouncement`]
pub fn event_into_repo(event: Event, repo_id: impl Into<String>) -> GitRepositoryAnnouncement {
let tags = &event.tags;
GitRepositoryAnnouncement {
id: repo_id.into(),
name: tags.map_tag(TagKind::Name, tag_value),
description: tags.map_tag(TagKind::Description, tag_value),
euc: tags
.map_marker(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::R)),
"euc",
parse_value,
)
.flatten(),
web: tags.dmap_tag(TagKind::Web, tag_values),
clone: tags.dmap_tag(TagKind::Clone, tag_values),
relays: tags.dmap_tag(TagKind::Relays, tag_values),
maintainers: tags.dmap_tag(TagKind::Maintainers, tag_values),
}
}
/// Returns a new string with leading and trailing whitespace removed.
pub fn str_trim(s: String) -> String {
s.trim().to_owned()
}
/// Returns a vector with duplicate elements removed.
pub fn dedup<I, T>(iter: I) -> Vec<T>
where
T: std::cmp::Ord,
I: Iterator<Item = T>,
{
let mut vector: Vec<T> = iter.collect();
vector.sort_unstable();
vector.dedup();
vector
}
/// Creates a new NIP-19 nevent string from an event ID and up to 3 unique relay
/// URLs.
pub fn new_nevent(event_id: EventId, relays: &[RelayUrl]) -> N34Result<String> {
Nip19Event::new(event_id)
.relays(
dedup(relays.iter().cloned())
.into_iter()
.take(3)
.collect::<Vec<_>>(),
)
.to_bech32()
.map_err(N34Error::from)
}
/// Creates a NIP-19 naddr string for a git repository announcement and up to 3
/// unique relay URLs.
pub fn repo_naddr(pubk: PublicKey, relays: &[RelayUrl]) -> N34Result<String> {
Nip19Coordinate::new(
Coordinate::new(Kind::GitRepoAnnouncement, pubk),
dedup(relays.iter().cloned()).iter().take(3),
)
.expect("Valid relays")
.to_bech32()
.map_err(N34Error::from)
}
/// Extends the given vector with write relays found in the event (if any).
pub fn add_write_relays(mut vector: Vec<RelayUrl>, event: Option<&Event>) -> Vec<RelayUrl> {
if let Some(event) = event {
vector.extend(
nip65::extract_owned_relay_list(event.clone())
.filter_map(|(r, m)| m.is_none_or(|m| m == RelayMetadata::Write).then_some(r)),
);
}
vector
}
/// Extends the given vector with read relays found in the event (if any).
pub fn add_read_relays(mut vector: Vec<RelayUrl>, event: Option<&Event>) -> Vec<RelayUrl> {
if let Some(event) = event {
vector.extend(
nip65::extract_owned_relay_list(event.clone())
.filter_map(|(r, m)| m.is_none_or(|m| m == RelayMetadata::Read).then_some(r)),
);
}
vector
}
/// Opens the user's default editor ($EDITOR) to edit a temporary file with
/// given suffix, then reads and returns the file contents. The temporary file
/// is automatically deleted.
pub fn read_editor(file_suffix: &str) -> N34Result<String> {
let Ok(editor) = std::env::var("EDITOR") else {
return Err(N34Error::EditorNotFound);
};
let temp_path = tempfile::NamedTempFile::with_suffix(file_suffix)?.into_temp_path();
// Disable the logs to not show up in a terminal text editor
crate::EDITOR_OPEN.store(true, Ordering::Relaxed);
let exit_status = std::process::Command::new(editor)
.arg(temp_path.to_str().expect("The path is valid utf8"))
.spawn()?
.wait()?;
crate::EDITOR_OPEN.store(false, Ordering::Relaxed);
if !exit_status.success() {
if let Some(code) = exit_status.code() {
tracing::warn!("The editor exit with `{code}` status")
}
}
let content = fs::read_to_string(&temp_path)
.map_err(N34Error::from)?
.trim()
.to_owned();
if content.is_empty() {
return Err(N34Error::EmptyEditorFile);
}
Ok(content)
}
/// Returns the given content if it's `Option::Some` or call [`read_editor`]
pub fn get_content<S>(content: Option<S>, file_suffix: &str) -> N34Result<String>
where
S: AsRef<str>,
{
if let Some(content) = content {
return Ok(content.as_ref().trim().to_owned());
}
read_editor(file_suffix)
}