57 Commits

Author SHA1 Message Date
5b8a09e9ca Add more unit tests for the config loader
1. Load exact match, supplement "[all]" table
2. Load no match, fall back to "[all]"
3. Load exact match, ignore missing "[all]" table
2025-07-20 13:22:59 -05:00
3453f64312 Wire in the conf file loading, assume PWD project
Load the configuration for the current directory. The project guessing
mechanism isn't here, yet, so this will have to do.

First take the properties set via Args. This will also capture the
values set through environment variables. For anything that's missing,
try to fill it with the info from the configuration files. In the event
that there isn't enough information, new error types have been added to
signal mis-use.
2025-07-20 12:33:38 -05:00
63d0a868ec Make the URL and Repo FQRN CLI args optional
They are no longer mandatory as they might be specified through the
config file(s). Now to go assemble that config and fix the compiler
errors.
2025-07-20 12:32:45 -05:00
4e9a5dd25b Delete a now-solved FIXME comment 2025-07-20 12:17:27 -05:00
ce480306e0 Cargo clippy fixes 2025-07-20 10:56:57 -05:00
6ca279de49 Autoformat 2025-07-20 10:51:03 -05:00
64215cefcc Remove WholeFile struct & anything that uses it 2025-07-20 10:47:32 -05:00
cf9b37fe99 Make default search paths available as util fn
It's the removed section from the get_config() function, but with an
extra Vec<_> creation. This is necessary here because the strings from
the environment variable don't live long enough for lazy evaluation.
2025-07-20 10:45:32 -05:00
ed76fa67ff Pass in search files rather than generating them
Now I can actually test the function!

The previous search locations are still what I'll want for normal
operation, though, so I'll be putting in a new util function to generate
them.
2025-07-20 10:24:45 -05:00
2e2c54d538 Complete the public get_config() function
"Now finish drawing the Owl."

I started assembling everything before realizing that I've been thinking
about the program backwards. The `WholeFile` struct is completely
unnecessary, as are several of the functions that help to create it.

I forgot that I don't need to collect all the project tables, only the
"[all]" table, and what ever the user is currently using. I want the
structure of a Map, not a list. I don't want this wrapper, I want the
toml::Value directly.
2025-07-19 21:09:09 -05:00
2b47460258 "Merge" method on PartialConfig
I'm going to roll the partial configurations together to get the most
complete version that I can. Add a function to make that easier.
2025-07-19 20:52:37 -05:00
b26a594cc8 Implement the load_from_file function
The implementation is dead simple, and pretty dumb. I'm not going to
figure out all the different IO errors I might see. Instead, the
function will report that it couldn't read the file and call it good.
2025-07-17 15:06:01 -05:00
246987fa68 Signature & tests for fn load_from_file()
This function almost writes itself. I need a thin layer to handle the
file IO errors and report them appropriately, and then all the magic is
a pass-through of the existing read_conf_str.

I've made basic unit tests for the most obvious scenarios. The test for
missing-file behavior is incomplete because I need to create a new error
variant.
2025-07-17 14:21:53 -05:00
551297f46b Remove some debug prints 2025-07-17 14:13:04 -05:00
912a7283fd Externalize the test table
I'm beginning work on the file reading functions, so I need some files
to read in my tests. I'll also need the WholeFile struct to compare
against.

The input string has been moved out into a file and put back into the
test fixture with `include_str!()`. The WholeFile construction has been
moved to a util function so I can reuse it in another test.
2025-07-17 14:04:17 -05:00
247c06dd9e Rename the config-string-reading function 2025-07-17 13:32:55 -05:00
cb314a8b4c Assert empty conf str is an error, TODO: semantics
The empty configuration string is some kind of an error, but I'm not
sure where and how to handle it. It should be treated as a soft error,
where I fall back to some hardcoded defaults.

There's a logic hole at the moment: The error I'm actually getting right
now is "NoSuchTable" because the "[all]" table doesn't exist. For a
totally empty config file, the above response should be used. But what
about a non-empty conf file? Is a missing "[all]" valid or not? For now,
assert the loader returns *an* error and leave behind a TODO for later.
2025-07-17 13:29:25 -05:00
277f638c60 Add a builder-pattern proj-path setter, for flavor
I like being able to chain methods instead of using a temporary variable
in between, so I've made one single function like I'm doing the builder
pattern.

But not really because there's nothing to build or finalize and such.
2025-07-17 12:13:14 -05:00
626973d2bc Extract PartCfg readers to a try_from impl
Don't repeat yourself. These property reading routines are actually
methods on the PartialConfig struct, so make them *actually* methods.

Because the table doesn't know it's own name, the path-specific config
needs to be updated with that external knowledge.
2025-07-17 12:08:35 -05:00
28539f54cc Use the get_table util to extract "[all]" table
I built the function for this purpose and then forgot to use it. I
remembered after doing the per-project bit, so here's the refactor.
2025-07-17 11:49:44 -05:00
5ce20adf2e Create-and-assign struct to whole.all
Minor refactor to make the "[all]" table read look like the per-project
table reads.
2025-07-17 11:47:18 -05:00
fc1e20185e Finish fn lconf(). Project-specific vals load
Loop over the keys (ignore the "all" one) and repeat the same property
extraction process.
2025-07-17 11:41:28 -05:00
15593204e0 Put the per-project test expects in for lconf()
Put all those per-project configs into the unit test. They're in the
input string, so we should expect to see them in the output struct.
2025-07-17 10:28:17 -05:00
330985940f Prototype load-config function
The `lconf()` function will eventually load the whole file, but for now
it reads in only the "[all]" table.

That "[all]" table will be used as the global fallback when per-project
settings are left unspecified.

The unit test "passes" but only because I've discarded those per-project
configs from the expected result. This is just so I can see clearly that
the all-table is loading properly.
2025-07-17 10:25:26 -05:00
213e0b4f4a Add partial- and whole- config structs 2025-07-17 10:23:00 -05:00
d27bea2c43 Util to get sometimes-empty config property
The get_property function needs to say that there is no property so that
the caller can respond appropriately. I'm going to need to frequently
respond to the "no such property" path by treating it as *not* an error.

If the config file doesn't specify a property, that's not an error, it's
just not specified and the default should be used instead. This util fn
makes that a bit more ergonomic.
2025-07-17 09:59:52 -05:00
30d8bcc6de Util fn's can use anything that impl's ToString
I don't want to remember to construct a `String` every single time I
want to call this function with a string literal. So I won't.

Make the functions generic over anything that implements the ToString
trait.
2025-07-17 09:57:49 -05:00
b2b9c8b9d9 Add a get-table util function 2025-07-16 19:16:43 -05:00
f6bab75644 Add property-get utility function
This function will look for a given property in a given table. It gives
back either the property, or an explanation of why it could not be
retrieved.
2025-07-14 21:43:08 -05:00
075a2ee921 Scaffold the new config module 2025-07-06 17:28:11 -05:00
8eacb510a2 Add a Cargo.toml & Git tag version comparison
I released the first couple versions without updating the value in
Cargo.toml. This will check for that happening again and abort the
build.
2025-07-06 12:54:52 -05:00
a23bdf3e34 Make the README title singular
It's not the "gt-tools" anymore, so maybe the README should match.
2025-07-05 17:21:59 -05:00
119831481e Bump to v2.2.0
All checks were successful
/ Compile and upload a release build (release) Successful in 44s
2025-07-03 18:13:09 -05:00
7246c7afb6 Oops, missed one 2025-07-03 18:05:18 -05:00
84eaaa1dbd Autoformat 2025-07-03 18:03:33 -05:00
c9dda5760c Prefix unused variables to quiet the linter 2025-07-03 17:56:07 -05:00
336f1453b9 Address most of the cargo-clippy lints 2025-07-03 17:56:07 -05:00
f068e8233e Release.colorized(), not std::fmt::Display
I don't know for sure if the string-ified version of a Release struct is
being printed to the terminal. As such, I don't know if the user wants,
does not want, or has mixed intentions for the stringification of this
thing.

No Display impl, instead just a `colorized()` method.
2025-07-03 17:47:50 -05:00
d4ef21e243 Change to free-fn intersperse for stdlib compat
Itertools warns that the standard library may be stabilizing the
intersperse method soon and recommends using this function instead.
2025-07-02 22:44:06 -05:00
d94c350cde Galaxy-brained newline intersperse function
Itertools already has an intersperse method for me. Why would I build my
own when I can do this? There's even a `fold()` over the units that come
out of the print routine.
2025-07-02 22:29:07 -05:00
8120cb0489 Remove trailing newline in Release item printout 2025-07-02 22:08:45 -05:00
b82cfdb822 Colorize the output! 2025-07-02 22:06:36 -05:00
ea046c929f Print releases in reverse order for easier reading
The result list has the newest item first, but I want to print them the
other way around. This way the newest (and presumably most interesting)
release is always the visible item, regardless of how many others have
printed and scrolled off screen.
2025-07-02 21:42:41 -05:00
135acf09b7 Basic impl Display for the Release struct
I'm not certain what info I want to present when listing the Releases.

The idea is that the release version is the most important, and that it
matches the git-tag associated with the release. I'll print that first.

Next, the name of the release followed by the body text. The list of
releases will become quite large for some projects, and the body text
may include a changelog. Both of these will cause the output to become
quite large. I will need to create a size limiter, but I'm ignoring that
for now.

Who created the release and when may be useful when searching for a
release, so I've included that as the final section.
2025-07-02 12:56:17 -05:00
136c051c82 Fix: incorrect field names for Attachment
All checks were successful
/ Compile and upload a release build (release) Successful in 37s
I think I got the names from the Go source code, but the API emits JSON
that has these names instead. The api/swagger guide even says as much.

This caused the super fun quirk that the upload actually succeedes, but
the program reports an error condition because of the deserialization
failure. Time to bump a minor revision!
2025-06-12 17:21:48 -05:00
a0ba8e7ea8 Use pre Rust 1.81 compatible file-exists test
The function `std::fs::exists(...)` was stabilized in Rust 1.81, which
means it can't be used in the Debian Bookworm build. This patch swaps to
a compatible implementation leaning on the std::path::Path struct.

I'm both "upstreaming" a Debian-specific patch I had to make for the
package, and fixing the additional usage now in `main.rs`. There doesn't
seem to be any compelling reason to avoid using this function, so I
figure I should merge it into the base release.
2025-06-12 16:05:51 -05:00
88cafc096f Drop unused import in api/release.rs 2025-06-08 10:43:32 -05:00
b200785e71 ... and the unit testing notes in README.md 2025-06-08 10:40:54 -05:00
8246337ae4 Delete the unit tests
They aren't useful anyway.
2025-06-08 00:06:59 -05:00
06795df3f7 Update main.rs to use new attachment iface 2025-06-07 23:57:19 -05:00
a5f6335b5f Add Attachment struct, new iface for create-rel
The Attachment struct exists, but this makes it glaringly obvious that
I've made a bad interface. The create_release_attachment should only
accept one file at a time and the loop over all files should happen in
main.rs

I've changed the signature, removed the loops, and wired in the newer
error handling routines. Needs fixing at call sites.
2025-06-07 23:50:16 -05:00
4a0addda67 Fold client-error-decode into a util function 2025-06-07 23:40:58 -05:00
0c70b584ba Interrogate create_release_attachment result 2025-06-07 23:30:56 -05:00
8a11c21b73 "Fix" the test case
I can't meaningfully unit test these things like this. I'll explore creating a tarball of a known Gitea configuration and using Docker to test against that. For now, just... kinda keep the test building.
2025-06-07 23:24:16 -05:00
d42cbbc1ec Drop unused imports 2025-06-07 23:22:48 -05:00
96e9ff4ce6 Interrogate create_release result more closely 2025-06-07 23:22:20 -05:00
6bdad44cc6 Interrogate list_releases result more closely 2025-06-07 23:15:39 -05:00
22 changed files with 543 additions and 412 deletions

View File

@@ -8,6 +8,11 @@ jobs:
name: Compile and upload a release build
steps:
- uses: actions/checkout@v4
- name: Get Cargo version
run: echo "cargo_version=v$(grep "^version" Cargo.toml | cut -d \" -f2)" >> $GITHUB_ENV
- name: Abort if Cargo.toml & Git Tag versions don't match
if: ${{ env.cargo_version != github.ref_name }}
run: exit 1
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@stable
- name: Build binary crate

View File

@@ -1,13 +1,16 @@
[package]
name = "gt-tool"
version = "1.0.0"
version = "2.2.0"
edition = "2024"
[dependencies]
clap = { version = "4.0.7", features = ["derive", "env"] }
colored = "2.0.0"
itertools = "0.10.0"
reqwest = { version = "0.11.13", features = ["json", "stream", "multipart"] }
serde = { version = "1.0.152", features = ["derive"] }
tokio = { version = "1.24.2", features = ["macros", "rt-multi-thread"] }
toml = "0.5"
# Packages available in Debian (Sid)
# clap = "4.5.23"

View File

@@ -1,4 +1,4 @@
# gt-tools
# gt-tool
CLI tools for interacting with the Gitea API. Use interactively to talk to your Gitea instance, or automatically via a CI/CD pipeline.
@@ -51,14 +51,3 @@ One of these, defaults to `help`:
| upload-release | Uploads one-or-more files to an existing release, identified by it's tag name. |
| help | prints the help text (the usage summary above). |
## Unit Testing
The unit test~~s~~ require a Gitea server to execute against. This information is supplied by environment variables rather than on the command line, but it is otherwise exactly the same usage.
| Variable | Description |
|-|-|
| TEST_GITEA_SERVER | Server URL, match `-u`, `--url` |
| TEST_GITEA_REPO | Owner + repo name, match `-u` `--repo` |
| TEST_GITEA_KEY | API key, match `RELEASE_KEY_GITEA`. The use of a new variable for the API token is to help avoid accidentally touching a production environment during test execution. |
| TEST_GITEA_RELEASE_TAG | Git tag used to identify the Release. Same as `upload-release`'s positional argument `<TAG_NAME>`. |

5
debian/changelog vendored
View File

@@ -1,5 +0,0 @@
gt-tool (1.0.0-1) UNRELEASED; urgency=low
* Experimental release.
-- Robert Garrett <robertgarrett404@gmail.com> Sun, 1 Jun 2025 16:05:00 -0500

30
debian/control vendored
View File

@@ -1,30 +0,0 @@
Source: gt-tool
Maintainer: Robert Garrett <robertgarrett404@gmail.com>
Section: misc
Priority: optional
Standards-Version: 4.6.2
Build-Depends:
debhelper-compat (= 13),
dh-cargo,
librust-clap-dev,
librust-reqwest-dev,
librust-tokio-dev,
librust-serde-dev,
Homepage: https://git.gelvin.dev/robert/gt-tool
Vcs-Git: https://git.gelvin.dev/robert/gt-tool
Vcs-Browser: https://git.gelvin.dev/robert/gt-tool
Rules-Requires-Root: no
Package: gt-tool
Architecture: any
Depends:
${misc:Depends},
${shlibs:Depends},
Description: CLI tools for interacting with the Gitea API.
Use interactively to talk to your Gitea instance, or automatically via a CI/CD
pipeline. Currently supports:
.
- showing the Releases for a project
- creating a new Release for a project
- attaching files to a release

43
debian/copyright vendored
View File

@@ -1,43 +0,0 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: gt-tools
Upstream-Contact: Robert Garrett <robertgarrett404@gmail.com>
Source: https://source.mnt.re/reform/mnt-reform-setup-wizard
Files: *
Copyright: 2025 Robert Garrett <robertgarrett404@gmail.com>
License: GPL-3+
Files: debian/*
Copyright: 2025 Robert Garrett <robertgarrett404@gmail.com>
License: GPL-3+
Files: debian/rules
Copyright:
Johannes Schauer Marin Rodrigues <josch@debian.org>
2025 Robert Garrett <robertgarrett404@gmail.com>
License: GPL-3+
Comment:
The debian/rules file is liften directly from the tuigreet package. It was
linked in the Debian Rust Team Book as a pretty simple example package. The
only change I've made is to remove the documentation generation target.
.
https://salsa.debian.org/debian/tuigreet/-/blob/master/debian/rules?ref_type=heads
License: GPL-3+
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.
.
It 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 it. If not, see <http://www.gnu.org/licenses/>.
.
On Debian systems, the full text of the GNU General Public License version 3
can be found in the file /usr/share/common-licenses/GPL-3.

6
debian/gbp.conf vendored
View File

@@ -1,6 +0,0 @@
[DEFAULT]
compression = xz
compression-level = 9
upstream-tag = v%(version)s
debian-branch = deb

View File

@@ -1,23 +0,0 @@
From: Robert Garrett <robertgarrett404@gmail.com>
Date: Sun, 1 Jun 2025 17:59:20 -0500
Subject: Rust edition downgrade to 2021
Debian Bookworm uses Rust 1.64 which only supports up to the 2021
edition.
---
Cargo.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Cargo.toml b/Cargo.toml
index febccc4..cf52754 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,7 +1,7 @@
[package]
name = "gt-tool"
version = "1.0.0"
-edition = "2024"
+edition = "2021"
[dependencies]
clap = { version = "4.0.7", features = ["derive", "env"] }

View File

@@ -1,31 +0,0 @@
From: Robert Garrett <robertgarrett404@gmail.com>
Date: Sun, 1 Jun 2025 18:01:21 -0500
Subject: Use pre Rust 1.81 compatible file-exists test
The function `std::fs::exists(...)` was stabilized in Rust 1.81, which
means it can't be used in the Debian Bookworm build. This patch swaps to
a compatible implementation leaning on the std::path::Path struct.
---
src/api/release_attachment.rs | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/api/release_attachment.rs b/src/api/release_attachment.rs
index 9af71bf..8a0c798 100644
--- a/src/api/release_attachment.rs
+++ b/src/api/release_attachment.rs
@@ -1,4 +1,4 @@
-use std::fs;
+use std::{fs, path};
pub fn check_release_match_repo() {}
pub fn get_release_attachment() {}
@@ -16,7 +16,8 @@ pub async fn create_release_attachment(
// Ensure all files exists before starting the uploads
for file in &files {
- match fs::exists(file) {
+ let path = path::Path::new(file);
+ match path.try_exists() {
Ok(true) => continue,
Ok(false) => return Err(crate::Error::NoSuchFile),
Err(e) => {

View File

@@ -1,2 +0,0 @@
0001-Rust-edition-downgrade-to-2021.patch
0002-Use-pre-Rust-1.81-compatible-file-exists-test.patch

26
debian/rules vendored
View File

@@ -1,26 +0,0 @@
#!/usr/bin/make -f
export DEB_BUILD_MAINT_OPTIONS = hardening=+all
DPKG_EXPORT_BUILDFLAGS = 1
include /usr/share/dpkg/default.mk
include /usr/share/rustc/architecture.mk
export DEB_HOST_RUST_TYPE
export PATH:=/usr/share/cargo/bin:$(PATH)
export CARGO=/usr/share/cargo/bin/cargo
export CARGO_HOME=$(CURDIR)/debian/cargo_home
export CARGO_REGISTRY=$(CURDIR)/debian/cargo_registry
export DEB_CARGO_CRATE=$(DEB_SOURCE)_$(DEB_VERSION_UPSTREAM)
%:
dh $@ --buildsystem=cargo
execute_after_dh_auto_clean:
$(CARGO) clean
rm -rf $(CARGO_HOME)
rm -rf $(CARGO_REGISTRY)
rm -f debian/cargo-checksum.json
execute_before_dh_auto_configure:
$(CARGO) prepare-debian $(CARGO_REGISTRY) --link-from-system
rm -f Cargo.lock
touch debian/cargo-checksum.json

View File

@@ -1 +0,0 @@
3.0 (quilt)

View File

@@ -1,14 +1,9 @@
use serde::{Deserialize, Serialize};
use crate::{
ApiError, Result,
structs::{
self,
release::{CreateReleaseOption, Release},
},
Result,
structs::release::{CreateReleaseOption, Release},
};
pub fn get_release(id: u64) -> Result<Release> {
pub fn get_release(_id: u64) -> Result<Release> {
todo!();
}
pub fn get_latest_release() -> Result<Release> {
@@ -22,24 +17,22 @@ pub async fn list_releases(
) -> Result<Vec<Release>> {
let request_url = format!("{gitea_url}/api/v1/repos/{repo}/releases/");
let req = client.get(request_url).send().await;
let response = req.map_err(|reqwest_err| crate::Error::WrappedReqwestErr(reqwest_err))?;
let release_list = response
.json::<Vec<Release>>()
.await
.map_err(|reqwest_err| {
// Convert reqwest errors to my own
// TODO: Create all error variants (see lib.rs)
crate::Error::WrappedReqwestErr(reqwest_err)
})?;
return Ok(release_list);
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(untagged)]
enum CreateResult {
Success(structs::release::Release),
ErrWithMessage(ApiError),
Empty,
let response = req.map_err(crate::Error::WrappedReqwestErr)?;
if response.status().is_success() {
let release_list = response
.json::<Vec<Release>>()
.await
.map_err(|reqwest_err| {
// Convert reqwest errors to my own
// TODO: Create all error variants (see lib.rs)
crate::Error::WrappedReqwestErr(reqwest_err)
})?;
return Ok(release_list);
} else if response.status().is_client_error() {
let mesg = crate::decode_client_error(response).await?;
return Err(crate::Error::ApiErrorMessage(mesg));
}
panic!("Reached end of list_releases without matching a return pathway.");
}
pub async fn create_release(
@@ -49,31 +42,27 @@ pub async fn create_release(
submission: CreateReleaseOption,
) -> Result<Release> {
let request_url = format!("{gitea_url}/api/v1/repos/{repo}/releases");
let req = client
let response = client
.post(request_url)
.json(&submission)
.send()
.await
.map_err(|e| crate::Error::from(e))?;
let new_release = req
.json::<CreateResult>()
.await
.map_err(|e| crate::Error::from(e))?;
match new_release {
CreateResult::Success(release) => Ok(release),
CreateResult::ErrWithMessage(api_error) => {
if api_error.message == "token is required" {
Err(crate::Error::MissingAuthToken)
} else {
Err(crate::Error::ApiErrorMessage(api_error))
}
}
CreateResult::Empty => panic!("How can we have 200 OK and no release info? No. Crash"),
.map_err(crate::Error::from)?;
if response.status().is_success() {
let new_release = response
.json::<Release>()
.await
.map_err(crate::Error::from)?;
return Ok(new_release);
} else if response.status().is_client_error() {
let mesg = crate::decode_client_error(response).await?;
return Err(crate::Error::ApiErrorMessage(mesg));
}
panic!("Reached end of create_release without matching a return path");
}
pub fn edit_release(id: u64) -> Result<Release> {
pub fn edit_release(_id: u64) -> Result<Release> {
todo!();
}
pub fn delete_release(id: u64) -> Result<()> {
pub fn delete_release(_id: u64) -> Result<()> {
todo!();
}

View File

@@ -1,4 +1,6 @@
use std::fs;
use std::{fs, path};
use crate::structs::Attachment;
pub fn check_release_match_repo() {}
pub fn get_release_attachment() {}
@@ -10,174 +12,47 @@ pub async fn create_release_attachment(
gitea_url: &str,
repo: &str,
release_id: usize,
files: Vec<String>,
) -> crate::Result<()> {
file: String,
) -> crate::Result<Attachment> {
let request_url = format!("{gitea_url}/api/v1/repos/{repo}/releases/{release_id}/assets");
// Ensure all files exists before starting the uploads
for file in &files {
match fs::exists(file) {
Ok(true) => continue,
Ok(false) => return Err(crate::Error::NoSuchFile),
Err(e) => {
eprintln!("Uh oh! The file-exists check couldn't be done: {e}");
panic!("TODO: Deal with scenario where the file's existence cannot be checked (e.g.: no permission)");
},
let path = path::Path::new(&file);
match path.try_exists() {
Ok(true) => (),
Ok(false) => return Err(crate::Error::NoSuchFile),
Err(e) => {
eprintln!("Uh oh! The file-exists check couldn't be done: {e}");
panic!(
"TODO: Deal with scenario where the file's existence cannot be checked (e.g.: no permission)"
);
}
}
for file in files {
println!("Uploading file {}", &file);
let data = reqwest::multipart::Part::stream(fs::read(&file).unwrap())
.file_name("attachment")
.mime_str("text/plain")?;
println!("Uploading file {}", &file);
let data = reqwest::multipart::Part::stream(fs::read(&file).unwrap())
.file_name("attachment")
.mime_str("text/plain")?;
let form = reqwest::multipart::Form::new().part("attachment", data);
let form = reqwest::multipart::Form::new().part("attachment", data);
let request = client
.post(&request_url)
.multipart(form)
.query(&[("name", file.split("/").last())])
.send()
.await?;
let response = client
.post(&request_url)
.multipart(form)
.query(&[("name", file.split("/").last())])
.send()
.await?;
if response.status().is_success() {
// TODO: create a struct Attachment and return it to the caller.
let attachment_desc = response
.json::<Attachment>()
.await
.map_err(crate::Error::from)?;
return Ok(attachment_desc);
} else if response.status().is_client_error() {
let mesg = crate::decode_client_error(response).await?;
return Err(crate::Error::ApiErrorMessage(mesg));
}
Ok(())
panic!("Reached end of release_attachment without matching a return path");
}
pub fn edit_release_attachment() {}
pub fn delete_release_attachment() {}
#[cfg(test)]
mod tests {
use reqwest::header::{self, ACCEPT};
use crate::structs::release::Release;
#[tokio::test]
async fn attach_file_exists() {
let conf = TestConfig::new();
let release_candidates =
crate::api::release::list_releases(
&conf.client,
&conf.server,
&conf.repo
)
.await
.expect("Failed to get releases. Pre-conditions unmet, aborting test!");
let release = match_release_by_tag(&conf.release_tag, release_candidates)
.expect("Failed to select matching release. Pre-conditions unmet, aborting test!");
let api_result = super::create_release_attachment(
&conf.client,
&conf.server,
&conf.repo,
release.id,
vec![String::from("Cargo.toml")],
)
.await;
}
#[tokio::test]
async fn attach_file_missing() {
let conf = TestConfig::new();
let release_candidates =
crate::api::release::list_releases(
&conf.client,
&conf.server,
&conf.repo
)
.await
.expect("Failed to get releases. Pre-conditions unmet, aborting test!");
let release = match_release_by_tag(&conf.release_tag, release_candidates)
.expect("Failed to select matching release. Pre-conditions unmet, aborting test!");
let api_result = super::create_release_attachment(
&conf.client,
&conf.server,
&conf.repo,
release.id,
vec![String::from("./this-file-doesnt-exist")],
)
.await;
let api_err = api_result.expect_err("Received Ok(()) after uploading non-existent file. That's nonsense, the API Function is wrong.");
match api_err {
crate::Error::Placeholder => panic!("Received dummy response from the API function. Finish implementing it, stupid"),
crate::Error::WrappedReqwestErr(error) => panic!("Received a reqwest::Error from the API function: {error}"),
crate::Error::MissingAuthToken => unreachable!("Missing auth token... in a unit test that already panics without the auth token..."),
crate::Error::NoSuchFile => (), // test passes
crate::Error::ApiErrorMessage(api_error) => panic!("Received an error message from the API: {api_error:?}"),
}
}
struct TestConfig {
server: String,
repo: String,
release_tag: String,
client: reqwest::Client,
}
impl TestConfig {
fn new() -> Self {
let server = std::env::var("TEST_GITEA_SERVER")
.expect("Must set server address in env var \"TEST_GITEA_SERVER\"");
let repo = std::env::var("TEST_GITEA_REPO")
.expect("Must set <user>/<repo> name in env var \"TEST_GITEA_REPO\"");
let token = format!(
"token {}",
std::env::var("TEST_GITEA_KEY")
.expect("Must set the API token in env var \"TEST_GITEA_KEY\"")
);
let release_tag = std::env::var("TEST_GITEA_RELEASE_TAG")
.expect("Must set the target release tag in env var \"TEST_GITEA_RELEASE_TAG\"");
let mut headers = reqwest::header::HeaderMap::new();
headers.append(ACCEPT, header::HeaderValue::from_static("application/json"));
headers.append("Authorization", token.parse().unwrap());
let client = reqwest::Client::builder()
.user_agent(format!(
"gt-tools-autotest-agent{}",
env!("CARGO_PKG_VERSION")
))
.default_headers(headers)
.build()
.expect("Failed to build reqwest::Client.");
return Self {
server,
repo,
release_tag,
client
};
}
}
// Testing utils
fn match_release_by_tag(tag: &String, releases: Vec<Release>) -> Option<Release> {
let mut release: Option<Release> = None;
for rel in releases {
if rel.tag_name == *tag {
// Only store the value if one hasn't been stored already
if let Some(first_release) = &release {
// if there was already a match, begin the error diagnostic creation.
let first_id = first_release.id;
let second_id = rel.id;
assert!(
first_id != second_id,
"FAILURE: Found the same release ID twice while scanning for duplicate tags. How did we get the same one twice?"
);
eprintln!("ERROR: Two releases have been found for the tag \"{tag}\".");
eprintln!("ERROR: first ID: {first_id}");
eprintln!("ERROR: second ID: {second_id}");
panic!("ERROR: Nonsense detected, I'm bailing out!");
} else {
// else, store our first (and hopefully only) match
release = Some(rel);
}
}
}
return release;
}
}

View File

@@ -4,9 +4,9 @@ use clap::{Parser, Subcommand};
#[command(version, about, long_about = None)]
pub struct Args {
#[arg(short = 'u', long = "url", env = "GTTOOL_GITEA_URL")]
pub gitea_url: String,
pub gitea_url: Option<String>,
#[arg(short = 'r', long = "repo", env = "GTTOOL_FQRN")]
pub repo: String,
pub repo: Option<String>,
#[command(subcommand)]
pub command: Commands,

313
src/config.rs Normal file
View File

@@ -0,0 +1,313 @@
use std::path::PathBuf;
use toml::{Value, value::Table};
pub type Result<T> = core::result::Result<T, Error>;
#[derive(Debug)]
#[cfg_attr(test, derive(PartialEq))]
pub enum Error {
BadFormat,
NoSuchProperty,
NoSuchTable,
CouldntReadFile,
TomlWrap(toml::de::Error),
}
impl From<toml::de::Error> for Error {
fn from(value: toml::de::Error) -> Self {
Error::TomlWrap(value)
}
}
impl core::fmt::Display for Error {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// FIXME: Print a nice output, don't just reuse the Debug impl
write!(fmt, "{self:?}")
}
}
impl std::error::Error for Error {}
/// Creates an iterator of default (Linux) search paths. The iterator output
/// is a list of files named "gt-tool.toml" found in decreasingly specific
/// configuration folders.
///
/// - any dirs listed in env var `$XDG_CONFIG_DIRS`
/// - and the `/etc` dir
///
/// This is so that user-specific configs are used first, then machine-wide
/// ones.
pub fn default_paths() -> impl Iterator<Item = PathBuf> {
// Read env var `XDG_CONFIG_DIRS` and split on ":" to get highest-priority list
// TODO: Emit warning when paths aren't unicode
std::env::var("XDG_CONFIG_DIRS")
.unwrap_or(String::from(""))
.split(":")
// Set up the "/etc" list
// Which is pretty silly, in this case.
// Maybe a future version will scan nested folders and this will make
// more sense.
// glue on the "/etc" path
.chain(["/etc"])
.map(|path_str| {
let mut path = PathBuf::from(path_str);
path.push("gt-tool.toml");
path
})
.collect::<Vec<_>>()
.into_iter()
}
/// Searches through the files, `search_files`, for configuration related to a
/// project, `project`.
///
/// The project string is used as a map key and should match the real path of a
/// project on disk. These are the table names for each config section.
///
/// The search files iterator must produce *files* not *folders.* For now,
/// there is no mechanism to ensure correct usage. Files that can't be opened
/// will quietly be skipped, so there will be no warning when one gives a
/// folder.
///
/// Use `fn default_paths()` to get a reasonable (Linux) default.
///
/// TODO: Check for, and warn or error when given a dir.
pub fn get_config(
project: &str,
search_files: impl Iterator<Item = PathBuf>,
) -> Result<PartialConfig> {
/*
1. Get conf search (from fn input)
2. Iterate config dirs
3. Try load toml::Value from file
4. Try-get proj-specific table
5. Try-get "[all]" table
6. (merge) Update `Option::None`s in proj-spec with `Some(_)`s from "[all]"
7. (merge, again) Fold the PartialConfigs into a finished one
*/
let file_iter = search_files;
let toml_iter = file_iter
.map(std::fs::read_to_string) // read text from file
.filter_map(|res| res.ok()) // remove any error messages
// TODO: Log warnings when files couldn't be read.
.map(|toml_text| toml_text.parse::<Value>()) // try convert to `toml::Value`
.filter_map(|res| res.ok()); // remove any failed parses
let config_iter = toml_iter
.map(|val| -> Result<PartialConfig> {
// Like `fn read_conf_str(...)`, but doesn't produce a `WholeFile`
// 1. Get the top-level table that is the config file
let cfg_table = val.as_table().ok_or(Error::BadFormat)?;
// 2. Get table
get_table(cfg_table, project)
// 3. convert to PartialConfig
.and_then(PartialConfig::try_from)
// 4. assemble a 2-tuple of PartialConfigs by...
.and_then(|proj| {
Ok((
// 4-1. Passing in the project-specific PartialConfig
proj.project_path(project),
// 4-2. Getting and converting to PartialConfig, or returning any Err() if one appears.
get_table(cfg_table, "all").and_then(PartialConfig::try_from)?,
))
})
.map(|pair| pair.0.merge(pair.1))
})
.filter_map(|res| res.ok())
.fold(PartialConfig::default(), |acc, inc| acc.merge(inc));
Ok(config_iter)
}
#[derive(Debug, Default)]
#[cfg_attr(test, derive(PartialEq))]
pub struct PartialConfig {
project_path: Option<String>,
pub gitea_url: Option<String>,
pub owner: Option<String>,
pub repo: Option<String>,
pub token: Option<String>,
}
impl PartialConfig {
// One lonely builder-pattern function to set the project path.
// This is so I can do continuation style calls instead of a bunch of
// successive `let conf = ...` temporaries.
fn project_path(self, path: impl ToString) -> Self {
PartialConfig {
project_path: Some(path.to_string()),
..self
}
}
// Merges two `PartialConfig`'s together, producing a new one.
// Non-None values on the right-hand side are used to replace values
// in the left-hand side, even if they are Some(_).
fn merge(self, other: Self) -> Self {
Self {
project_path: other.project_path.or(self.project_path),
gitea_url: other.gitea_url.or(self.gitea_url),
owner: other.owner.or(self.owner),
repo: other.repo.or(self.repo),
token: other.token.or(self.token),
}
}
}
impl TryFrom<&Table> for PartialConfig {
type Error = crate::config::Error;
fn try_from(value: &Table) -> Result<Self> {
Ok(Self {
// can't get table name because that key is gone by this point.
project_path: None,
gitea_url: get_maybe_property(value, "gitea_url")?.cloned(),
owner: get_maybe_property(value, "owner")?.cloned(),
repo: get_maybe_property(value, "repo")?.cloned(),
token: get_maybe_property(value, "token")?.cloned(),
})
}
}
/// The outer value must be a Table so we can get the sub-table from it.
fn get_table(outer: &Table, table_name: impl ToString) -> Result<&Table> {
outer
.get(&table_name.to_string())
.ok_or(Error::NoSuchTable)?
.as_table()
.ok_or(Error::BadFormat)
}
/// Similar to `get_property()` but maps the "Error::NoSuchProperty" result to
/// Option::None. Some properties aren't specified, and that's okay... sometimes.
fn get_maybe_property(outer: &Table, property: impl ToString) -> Result<Option<&String>> {
let maybe_prop = get_property(outer, property);
match maybe_prop {
Ok(value) => Ok(Some(value)),
Err(e) => {
if let Error::NoSuchProperty = e {
Ok(None)
} else {
Err(e)
}
}
}
}
/// The config properties are individual strings. This gets the named property,
/// or an error explaining why it couldn't be fetched.
fn get_property(outer: &Table, property: impl ToString) -> Result<&String> {
let maybe_prop = outer
.get(&property.to_string())
.ok_or(Error::NoSuchProperty)?;
if let Value::String(text) = maybe_prop {
Ok(text)
} else {
Err(Error::BadFormat)
}
}
#[cfg(test)]
mod tests {
use toml::map::Map;
use super::*;
#[test]
fn read_single_prop() -> Result<()> {
let fx_input_str = "owner = \"dingus\"";
let fx_value = fx_input_str.parse::<Value>()?;
let fx_value = fx_value.as_table().ok_or(Error::NoSuchTable)?;
let expected = "dingus";
let res = get_property(&fx_value, String::from("owner"))?;
assert_eq!(res, expected);
Ok(())
}
// The property is given the value of empty-string `""`
#[test]
fn read_single_prop_empty_quotes() -> Result<()> {
let fx_input_str = "owner = \"\"";
let fx_value = fx_input_str.parse::<Value>()?;
let fx_value = fx_value.as_table().ok_or(Error::NoSuchTable)?;
let expected = "";
let res = get_property(&fx_value, String::from("owner"))?;
assert_eq!(res, expected);
Ok(())
}
#[test]
fn read_table() -> Result<()> {
let fx_input_str = "[tab]\nwith_a_garbage = \"value\"";
let fx_value = fx_input_str.parse::<Value>()?;
let fx_value = fx_value.as_table().ok_or(Error::BadFormat)?;
let mut expected: Map<String, Value> = Map::new();
expected.insert(
String::from("with_a_garbage"),
Value::String(String::from("value")),
);
let res = get_table(&fx_value, String::from("tab"))?;
assert_eq!(res, &expected);
Ok(())
}
#[test]
fn test_get_config_with_specific_match() -> Result<()> {
let search_paths = ["./test_data/sample_config.toml"]
.into_iter()
.map(PathBuf::from);
let load_result = get_config("/home/robert/projects/gt-tool", search_paths)?;
let expected = PartialConfig {
project_path: Some(String::from("/home/robert/projects/gt-tool")),
owner: Some(String::from("robert")),
repo: Some(String::from("gt-tool")),
gitea_url: Some(String::from("http://localhost:3000")),
token: Some(String::from("fake-token")),
};
assert_eq!(load_result, expected);
Ok(())
}
// Ensure the config comes back with something even when there is no
// matching project-specific table.
#[test]
fn test_get_config_no_specific_match() -> Result<()> {
let search_paths = ["./test_data/sample_config.toml"]
.into_iter()
.map(PathBuf::from);
let load_result = get_config("/no/such/project", search_paths)?;
let expected = PartialConfig {
project_path: None,
owner: None,
repo: None,
gitea_url: Some(String::from("http://localhost:3000")),
token: Some(String::from("fake-token")),
};
assert_eq!(load_result, expected);
Ok(())
}
// Ensure the config comes back with something even when there is no
// "[all]" table
#[test]
fn test_get_config_without_all() -> Result<()> {
let search_paths = ["./test_data/missing_all_table.toml"]
.into_iter()
.map(PathBuf::from);
let load_result = get_config("/some/other/path", search_paths)?;
let expected = PartialConfig {
gitea_url: Some(String::from("fake-url")),
..PartialConfig::default()
};
assert_eq!(load_result, expected);
Ok(())
}
}

View File

@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
pub mod api;
pub mod cli;
pub mod config;
pub mod structs;
#[derive(Debug, Deserialize, Serialize)]
@@ -10,9 +11,19 @@ pub struct ApiError {
url: String,
}
pub(crate) async fn decode_client_error(response: reqwest::Response) -> Result<ApiError> {
response
.json::<ApiError>()
.await
.map_err(crate::Error::WrappedReqwestErr)
}
#[derive(Debug)]
pub enum Error {
Placeholder, // TODO: Enumerate error modes
MissingGiteaUrl, // the gitea URL wasn't specified on the CLI, env, or config file.
MissingRepoFRQN, // either the owner, repo, or both weren't specified in the loaded PartialConfig
WrappedConfigErr(config::Error),
WrappedReqwestErr(reqwest::Error),
MissingAuthToken,
NoSuchFile, // for release attachment 'file exists' pre-check.
@@ -26,4 +37,10 @@ impl From<reqwest::Error> for crate::Error {
}
}
impl From<crate::config::Error> for crate::Error {
fn from(value: crate::config::Error) -> Self {
Self::WrappedConfigErr(value)
}
}
type Result<T> = core::result::Result<T, Error>;

View File

@@ -1,3 +1,5 @@
use std::path;
use gt_tool::cli::Args;
use gt_tool::structs::release::{CreateReleaseOption, Release};
@@ -10,6 +12,34 @@ use reqwest::header::ACCEPT;
async fn main() -> Result<(), gt_tool::Error> {
let args = Args::parse();
// TODO: Heuristics to guess project path
// See issue #8: https://git.gelvin.dev/robert/gt-tool/issues/8
let pwd = std::env::current_dir()
.map_err(|_e| gt_tool::Error::WrappedConfigErr(
gt_tool::config::Error::CouldntReadFile
))?;
let config = gt_tool::config::get_config(
pwd.to_str().expect("I assumed the path can be UTF-8, but that didn't work out..."),
gt_tool::config::default_paths()
)?;
println!("->> Loaded Config: {config:?}");
// arg parser also checks the environment. Prefer CLI/env, then config file.
let gitea_url = args.gitea_url.or(config.gitea_url).ok_or(gt_tool::Error::MissingGiteaUrl)?;
// Config files split the repo FQRN into "owner" and "repo" (confusing naming, sorry)
// These must be merged back together and passed along.
let conf_fqrn = config.owner
.ok_or(gt_tool::Error::MissingRepoFRQN)
.and_then(| mut own| {
let repo = config.repo.ok_or(gt_tool::Error::MissingRepoFRQN)?;
own.push_str("/");
own.push_str(&repo);
Ok(own)
});
let repo_fqrn = args.repo
.ok_or(gt_tool::Error::MissingRepoFRQN)
.or(conf_fqrn)?;
let mut headers = reqwest::header::HeaderMap::new();
headers.append(ACCEPT, header::HeaderValue::from_static("application/json"));
@@ -19,20 +49,23 @@ async fn main() -> Result<(), gt_tool::Error> {
headers.append("Authorization", token.parse().unwrap());
}
let client = reqwest::Client::builder()
.user_agent(format!(
"gt-tools-agent-{}",
env!("CARGO_PKG_VERSION")
))
.user_agent(format!("gt-tools-agent-{}", env!("CARGO_PKG_VERSION")))
.default_headers(headers)
.build()?;
match args.command {
gt_tool::cli::Commands::ListReleases => {
let releases =
gt_tool::api::release::list_releases(&client, &args.gitea_url, &args.repo).await?;
for release in releases {
println!("{:?}", release);
}
gt_tool::api::release::list_releases(&client, &gitea_url, &repo_fqrn).await?;
// Print in reverse order so the newest items are closest to the
// user's command prompt. Otherwise the newest item scrolls off the
// screen and can't be seen.
itertools::Itertools::intersperse(
releases.iter().rev().map(|release| release.colorized()),
String::from(""),
)
.map(|release| println!("{}", release))
.fold((), |_, _| ());
}
gt_tool::cli::Commands::CreateRelease {
name,
@@ -49,13 +82,8 @@ async fn main() -> Result<(), gt_tool::Error> {
tag_name,
target_commitish,
};
gt_tool::api::release::create_release(
&client,
&args.gitea_url,
&args.repo,
submission,
)
.await?;
gt_tool::api::release::create_release(&client, &gitea_url, &repo_fqrn, submission)
.await?;
}
gt_tool::cli::Commands::UploadRelease {
tag_name,
@@ -75,17 +103,32 @@ async fn main() -> Result<(), gt_tool::Error> {
// Grab all, find the one that matches the input tag.
// Scream if there are multiple matches.
let release_candidates =
gt_tool::api::release::list_releases(&client, &args.gitea_url, &args.repo).await?;
gt_tool::api::release::list_releases(&client, &gitea_url, &repo_fqrn).await?;
if let Some(release) = match_release_by_tag(&tag_name, release_candidates) {
gt_tool::api::release_attachment::create_release_attachment(
&client,
&args.gitea_url,
&args.repo,
release.id,
files,
)
.await?;
for file in &files {
let path = path::Path::new(&file);
match path.try_exists() {
Ok(true) => continue,
Ok(false) => return Err(gt_tool::Error::NoSuchFile),
Err(e) => {
eprintln!("Uh oh! The file-exists check couldn't be done: {e}");
panic!(
"TODO: Deal with scenario where the file's existence cannot be checked (e.g.: no permission)"
);
}
}
}
for file in files {
let _attach_desc = gt_tool::api::release_attachment::create_release_attachment(
&client,
&gitea_url,
&repo_fqrn,
release.id,
file,
)
.await?;
}
} else {
println!("ERR: Couldn't find a release matching the tag \"{tag_name}\".");
return Err(gt_tool::Error::NoSuchRelease);
@@ -127,5 +170,5 @@ fn match_release_by_tag(tag: &String, releases: Vec<Release>) -> Option<Release>
}
}
}
return release;
release
}

View File

@@ -1,2 +1,15 @@
use serde::{Deserialize, Serialize};
pub mod release;
pub mod repo;
#[derive(Debug, Deserialize, Serialize)]
pub struct Attachment {
id: usize,
name: String,
size: i64,
download_count: i64,
created_at: String, // TODO: Date-time struct
uuid: String,
browser_download_url: String,
}

View File

@@ -1,3 +1,4 @@
use colored::Colorize;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
@@ -19,6 +20,36 @@ pub struct Release {
author: Author,
}
impl Release {
pub fn colorized(&self) -> String {
let tag = "Tag:".green().bold();
let name = "Name:".green();
let published = "Published:".bright_green();
let created = "Created:".green().dimmed();
let author = "Author:".blue();
let body = if !self.body.is_empty() {
&self.body.white()
} else {
&String::from("(empty body)").dimmed()
};
format!(
"{tag} {}
{name} {}
{}
{published} {} ({created} {})
{author} {} ({})",
self.tag_name.bold(),
self.name,
body,
self.published_at,
self.created_at.dimmed(),
self.author.login,
self.author.email,
)
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Author {
id: usize,

View File

@@ -0,0 +1,5 @@
# There must be an "[all]" table or the loader will reject the config file.
["/some/other/path"]
gitea_url = "fake-url"

View File

@@ -0,0 +1,15 @@
[all]
gitea_url = "http://localhost:3000"
token = "fake-token"
["/home/robert/projects/gt-tool"]
owner = "robert"
repo = "gt-tool"
["/home/robert/projects/rcalc"]
owner = "jamis"
repo = "rcalc"
["/home/robert/projects/rcalc-builders"]
owner = "jamis"
repo = "rcalc"