71 Commits

Author SHA1 Message Date
25af28e97c Update changelog for 3.0.1-1 release 2025-11-13 17:12:47 -06:00
02cabfeb1a Drop entirety of old patch set
Both the Rust edition and the lifetime thing were changed in Rust 1.85,
which is now available in Debian 13. These patches are no longer
necessary.
2025-11-13 17:08:53 -06:00
54b54b42d7 Update gbp.conf debian-branch to "deb/trixie" 2025-11-13 17:08:31 -06:00
21799aa247 Merge tag 'v3.0.1' into deb/trixie
Release v3.0.1, metadata updates
2025-11-13 17:07:52 -06:00
c5c5598fb7 Mark v3.0.1 patch
All checks were successful
/ Compile and upload a release build (release) Successful in 54s
Barely anything has changed, but the package *is* different. v3 is from
months back and is missing information that Crates.io kinda wants.
2025-11-13 16:55:12 -06:00
9e47cb72d5 Remove the comments tracking Debian-specific deps
The Debian 12 dependency versions can go away since I'm no longer
targetting it. The Debian Sid versions haven't been checked in months,
and I'm not actually targetting it *either* (never have been). They can
both go away.
2025-11-13 16:40:01 -06:00
ff2286f44b Add metadata required for publishing to crates.io
I'm not sure they're required. I sure hope not because I don't have a
homepage, and the guide says not to reuse the repo URL there.
2025-11-13 16:36:54 -06:00
d982f42ae7 Update dependency versions for Debian 13 "Trixie"
I'm deciding that I'll only support the latest stable release of
Debian. Somehow I doubt anyone is using this tool, and those who do are
unlikely to need an even longer support window than Debian's stable
release period.

This change bumps the dependencies to match those available in Debian
13. Some upgrades would have already happened, while others are blocked
by the SemVer rules.

For example, Clap 4.0 to 4.5 would happen automatically, but TOML 0.5 to
0.8 would not.
2025-09-11 14:12:12 -05:00
daf9ec6ae6 Update changelog for 3.0.0-1 release 2025-07-22 09:54:41 -05:00
fc44129039 Rediff patches 2025-07-22 09:54:05 -05:00
a88e44ab97 Add new upstream dependency to debian/control
Also sort the dependencies alphabetically.
2025-07-22 09:52:42 -05:00
17165a58bb Merge tag 'v3.0.0' into deb/bookworm 2025-07-22 09:52:29 -05:00
641efc3bf7 Update automation workflow with new CLI args
All checks were successful
/ Compile and upload a release build (release) Successful in 1m10s
2025-07-22 09:41:54 -05:00
144fba5373 Bump crate version to v3.0.0
Some checks failed
/ Compile and upload a release build (release) Failing after 49s
2025-07-21 16:37:37 -05:00
7f35b808e5 Lint and format 2025-07-21 16:37:14 -05:00
00edaf87ce Mark the file-format and search-path conf sections
There are two concepts here and that should be more clearly indicated.

Introduce the file format with some examples, then talk about where
those files are found.
2025-07-21 16:21:10 -05:00
250140a954 Rephrase the all-projects setting introduction 2025-07-21 16:20:48 -05:00
b290a8b1d6 Drop the "no-repo" comment in TOML example
It's not relevant to the example and might confuse readers as a result.
2025-07-21 16:18:44 -05:00
b952e40060 Revise explanation of --project option
I need to introduce the idea that "projects" are actually file paths,
and that these paths are the keys for the key-value stores that are the
config files.

...but without saying "HashMap" because that's really an implementation
detail.
2025-07-21 16:14:47 -05:00
4b9257a9a7 Rename remaining CLI arg sections
The previous text was pretty ugly and not particularly useful to catch
the eye when looking for relevant sections.
2025-07-21 16:13:46 -05:00
d34eda77dc Delete the old CLI option sections 2025-07-21 16:12:56 -05:00
3315c18ed2 New 'authentication' section
The auth tokens can now be loaded from the config files, so I need to
mention that.

I took the opportunity to revise the explanation of when auth is
required. Now it has a more obvious example of how it depends on
instance configuration.
2025-07-21 16:09:12 -05:00
0e7bca80cb Create a short, complete explanation of req. info.
I don't need to have nearly so much information explaining how to use
optional command line arguments. The quirk about the "repo" needing to
be a URL fragment somewhat justified the extra explanation, but that's
gone now.

Instead, a short, up-front section stating which bits are required and
where the program will try to get them.
2025-07-21 16:08:08 -05:00
5bd2862498 Update usage printout 2025-07-21 15:47:23 -05:00
333636b524 Revise help text for CLI "--project" arg 2025-07-21 14:57:18 -05:00
e954a2b09a Drop notice about CLI not having "repo" & "owner" 2025-07-21 14:54:16 -05:00
da8f008f1a Use current-dir as final fallback repo name
It all falls into place! I had been dreading doing this bit, but after
updating the usage guide I realized the CLI args should be split, too.
Which finally means that I can just glue on the PWD name as a final
fallback for the repo name.

Try the args, then the config file(s), then PWD. If nothing works, the
user is in a world of hurt. Bail out.
2025-07-21 14:48:30 -05:00
7c0966be30 Split the owner and repo args apart in CLI parser 2025-07-21 14:21:13 -05:00
c1019afa7a Write configuration guide in the README 2025-07-21 14:04:04 -05:00
1a619d7bb4 Update CLI usage guide, add project lookup guide
There's a new inconsistency, however. The previous URL and FQRN
arguments are no longer mandatory but their description makes it seem
as though they are.
2025-07-21 14:04:01 -05:00
fc0d1b569c Add a project path CLI option 2025-07-21 11:56:20 -05:00
8cfc6605c9 Mark pre-release 3.0.0-alpha.1
The configuration file loading is complete and seems to work the way I
expect. I still need to do an improved project guessing system, and it
would be smart to allow the user to explicitly enter a config file to
use.
2025-07-21 10:58:37 -05:00
0e814b86a1 Fix some clippy lints 2025-07-21 10:52:20 -05:00
0e3aa16e00 Another autoformat 2025-07-20 17:36:49 -05:00
04dd333d72 Fix: use default "[all]" if one isn't present
Same thing as the previous commit, but for the "[all]" table.
2025-07-20 17:33:55 -05:00
13ef1d25eb Fix: use empty PartialConfig if proj conf missing
If there is no project-specific configuration, use a default one
instead. It still needs to be merged with the "[all]" one, assuming that
exists.

Now to do the same thing for the all-table.
2025-07-20 17:32:11 -05:00
56b0580a9a Add docstring for PartialConfig::try_from()
I started to replace this with an infallible `try()` implementation
before realizing that this exists specifically to filter out the
no-such-table result. That isn't an error *in this context*, which is
what the try_from() is doing for me.
2025-07-20 17:23:44 -05:00
46d8618e74 Fix config unit tests: project path is set!
The project path value gets set as a side-effect of loading the named
configuration table. Which... actually means this information isn't
important. I know it going in, and I know it coming out. I think the
real fix is to delete the field.
2025-07-20 17:23:15 -05:00
73363718c3 Add test for skipping unavailable conf files
Missing config files aren't an error. Make sure there isn't some kind of
early return logic that emits broken data.
2025-07-20 16:06:25 -05:00
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
15 changed files with 636 additions and 117 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
@@ -17,7 +22,9 @@ jobs:
- name: Upload the program (using itself!)
run: >
target/release/gt-tool-${{ github.ref_name }}-$(arch)
-u ${{ vars.DEST_GITEA }} -r ${{ vars.DEST_REPO }}
-u ${{ vars.DEST_GITEA }}
-o ${{ vars.DEST_OWNER }}
-r ${{ vars.DEST_REPO }}
upload-release
"${{ github.ref_name }}"
target/release/gt-tool-${{ github.ref_name }}-$(arch)

View File

@@ -1,22 +1,18 @@
[package]
name = "gt-tool"
version = "2.2.0"
version = "3.0.1"
edition = "2024"
license = "GPL-3.0-only"
description = "CLI tools for interacting with the Gitea API. Mainly for attaching files to releases."
# homepage = "" I have no website for a project home page :(
repository = "https://git.gelvin.dev/robert/gt-tool"
readme = "README.md"
[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"] }
# Packages available in Debian (Sid)
# clap = "4.5.23"
# reqwest = "0.12.15"
# tokio = "1.43.1"
# Debian (Bookworm)
# clap = "4.0.32"
# reqwest = "0.11.13"
# tokio = "1.24.2"
clap = { version = "4.5.23", features = ["derive", "env"] }
colored = "2.2.0"
itertools = "0.13.0"
reqwest = { version = "0.12.15", features = ["json", "stream", "multipart"] }
serde = { version = "1.0.217", features = ["derive"] }
tokio = { version = "1.43.1", features = ["macros", "rt-multi-thread"] }
toml = "0.8.19"

107
README.md
View File

@@ -1,11 +1,11 @@
# 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.
## Usage
```txt
Usage: gt-tools --url <GITEA_URL> --repo <REPO> <COMMAND>
Usage: gt-tool [OPTIONS] <COMMAND>
Commands:
list-releases
@@ -15,34 +15,51 @@ Commands:
Options:
-u, --url <GITEA_URL> [env: GTTOOL_GITEA_URL=]
-r, --repo <REPO> [env: GTTOOL_FQRN=]
-o, --owner <OWNER> [env: GTTOOL_OWNER=]
-r, --repo <REPO> [env: GTTOOL_REPO=]
-p, --project <PROJECT> Path to project (relative or absolute). Used to override configuration selection.
-h, --help Print help
-V, --version Print version
```
### Required Information
To function, this program requires knowledge of these items:
- Gitea URL
- Owner of repository
- Repository name
This info will be gathered from these locations, in order of priority:
1. CLI argument
2. Environment variable
3. Configuration files
It's worth noting that the "owner" is the entity that controls the repo on the Gitea instance. This will be the first part of the route in the URL: `http://demo.gitea.com/{owner}`.
Likewise, the "repo" is what ever the Gitea instance thinks it's called -- which doesn't have to match anyone's local copy! It will be the second part of the route in the URL: `http://demo.gitea.com/{owner}/{repo}`.
### Authentication
Authentication is token-based via environment variable `RELEASE_KEY_GITEA`.
Authentication is token-based. There is no CLI option to prevent the token from appearing in any command logs.
Ensure your token has the appropriate access for your usage. This depends on what you're doing and how your Gitea instance is configured, so you'll have to figure it out for yourself.
In order of priority, the token is loaded from:
Most likely, you will need a token with "repository: read-and-write" permissions. See Gitea's documentation on [token scopes](https://docs.gitea.com/development/oauth2-provider#scopes) for more.
1. The environment variable `RELEASE_KEY_GITEA`
2. Config files, key `token`
### `<GITEA_URL>`:
Whether or not it is required depends on how your Gitea instance and the repositories inside are configured. For example, a default Gitea configuration will allow unauthenticated users to see public repositories but not make any changes. This means no token is required to run `gt-tool list-releases`, while `gt-tool upload-release` *will* require a token.
The Gitea server URL must be provided with `--url` or `-u` on the command line, or via the environment variable `GTTOOL_GITEA_URL`. Use the base URL for your Gitea instance.
For details, see Gitea's documentation on [token scopes](https://docs.gitea.com/development/oauth2-provider#scopes).
E.g.: Using the Gitea org's demo instance, it would be: `--url "https://demo.gitea.com/"`
### The `--project` option
### `<REPO>`:
Settings retrieved from config files are selected based on the project's path. By default, the current directory will be used. In case that guess is incorrect, this option can be specified with another path.
The repository name must be provided with `--repo` or `-u` on the command line, or via the environment variable `GTTOOL_GITEA_FQRN` ("fully qualified repo name"). Use the format `<owner>/<repo>`, which is the route immediately following the GITEA_URL base. This is how GitHub and Gitea identify repos in the URL, and how Golang locates it's modules, so this tool does the same.
See [configuration](#configuration) for details on format and file locations.
E.g.: `--repo "go-gitea/gitea"` would name the Gitea repo belonging to the go-gitea organization.
### `<COMMAND>`:
One of these, defaults to `help`:
### Commands:
| Command | Description |
|-|-|
@@ -51,3 +68,61 @@ 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). |
## Configuration
Instead of specifying everything on the command line every single run, some TOML files can be used to persist project settings.
> Exporting some environment variables would be similar, but that would be *more* annoying when working on multiple projects. One would have to constantly re-export the settings or use two shells. But then there's the issue of losing track of which shell has which settings.
### File Format
Settings are retrieved from a table named the same as the project's path on disk. For example, gt_tool itself could have an entry as follows:
```toml
["/home/robert/projects/gt-tool"]
gitea_url = "https://demo.gitea.com/"
owner = "dummy"
repo = "gt-tool"
token = "fake-token"
```
Sometimes one may want to apply a setting to all projects. For this, they can use the special `[all]` table.
```toml
[all]
gitea_url = "https://demo.gitea.com/"
```
Since the more-specific settings are preferred, these can be combined to have an override effect.
```toml
[all]
gitea_url = "https://demo.gitea.com/"
owner = "robert"
token = "fake-token"
# Override Gitea target so I can test my uploads privately.
["/home/robert/projects/gt-tool"]
gitea_url = "http://localhost:3000"
repo = "gt-tool"
```
### Search Paths
Similar to how unspecified project settings will fall back to those in the "`[all]`" table, whole files will fall back to other, lower priority files.
1. First, each dir in `$XDG_CONFIG_DIRS` is scanned for a `gt-tool.toml` file.
2. Then, `/etc/gt-tool.toml`.
> All config files **MUST** be named named `gt-tool.toml`.
### Recognized Keys
| Key | Description |
|-|-|
| gitea_url | URL of the Gitea server. Same as `-u`, `--url`, and `$GTTOOL_GITEA_URL`. |
| owner | Owner of the repository (individual, or organization). Combined with "repo" key to produce the fully-qualified-repo-name. Front-half of `-r`, `--repo`, and `$GTTOOL_FQRN` |
| repo | Name of the repository on the Gitea server. Combined with "owner" key to produce the fully-qualified-repo-name. Back-half of `-r`, `--repo`, and `$GTTOOL_FQRN` |
| token | Gitea auth token, exactly the same as `$RELEASE_KEY_GITEA` |
Additional keys are quietly ignored. The config loading is done by querying a HashMap, so anything not touched doesn't get inspected. The only requirements are that the file is valid TOML, and that these keys are all strings.h

77
debian/changelog vendored
View File

@@ -1,3 +1,80 @@
gt-tool (3.0.1-1) unstable; urgency=medium
* Update dependency versions for Debian 13 "Trixie"
* Add metadata required for publishing to crates.io
* Remove the comments tracking Debian-specific deps
* Mark v3.0.1 patch
* Update gbp.conf debian-branch to "deb/trixie"
* Drop entirety of old patch set
-- Robert Garrett <robertgarrett404@gmail.com> Thu, 13 Nov 2025 17:12:26 -0600
gt-tool (3.0.0-1) unstable; urgency=medium
* Make the README title singular
* Add a Cargo.toml & Git tag version comparison
* Scaffold the new config module
* Add property-get utility function
* Add a get-table util function
* Util fn's can use anything that impl's ToString
* Util to get sometimes-empty config property
* Add partial- and whole- config structs
* Prototype load-config function
* Put the per-project test expects in for lconf()
* Finish `fn lconf()`. Project-specific vals load
* Create-and-assign struct to whole.all
* Use the get_table util to extract "[all]" table
* Extract PartCfg readers to a try_from impl
* Add a builder-pattern proj-path setter, for flavor
* Assert empty conf str is an error, TODO: semantics
* Rename the config-string-reading function
* Externalize the test table
* Remove some debug prints
* Signature & tests for fn load_from_file()
* Implement the load_from_file function
* "Merge" method on PartialConfig
* Complete the public `get_config()` function
* Pass in search files rather than generating them
* Make default search paths available as util fn
* Remove `WholeFile` struct & anything that uses it
* Autoformat
* Cargo clippy fixes
* Delete a now-solved FIXME comment
* Make the URL and Repo FQRN CLI args optional
* Wire in the conf file loading, assume PWD project
* Add more unit tests for the config loader
* Add test for skipping unavailable conf files
* Fix config unit tests: project path is set!
* Add docstring for PartialConfig::try_from()
* Fix: use empty PartialConfig if proj conf missing
* Fix: use default "[all]" if one isn't present
* Another autoformat
* Fix some clippy lints
* Mark pre-release 3.0.0-alpha.1
* Add a project path CLI option
* Update CLI usage guide, add project lookup guide
* Write configuration guide in the README
* Split the owner and repo args apart in CLI parser
* Use current-dir as final fallback repo name
* Drop notice about CLI not having "repo" & "owner"
* Revise help text for CLI "--project" arg
* Update usage printout
* Create a short, complete explanation of req. info.
* New 'authentication' section
* Delete the old CLI option sections
* Rename remaining CLI arg sections
* Revise explanation of `--project` option
* Drop the "no-repo" comment in TOML example
* Rephrase the all-projects setting introduction
* Mark the file-format and search-path conf sections
* Lint and format
* Bump crate version to v3.0.0
* Update automation workflow with new CLI args
* Add new upstream dependency to debian/control
* Rediff patches
-- Robert Garrett <robertgarrett404@gmail.com> Tue, 22 Jul 2025 09:54:28 -0500
gt-tool (2.2.0-2) unstable; urgency=medium
* Fix: "Source:" URL in debian/copyright

3
debian/control vendored
View File

@@ -10,8 +10,9 @@ Build-Depends:
librust-colored-dev,
librust-itertools-dev,
librust-reqwest-dev,
librust-tokio-dev,
librust-serde-dev,
librust-tokio-dev,
librust-toml-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

2
debian/gbp.conf vendored
View File

@@ -2,5 +2,5 @@
compression = xz
compression-level = 9
upstream-tag = v%(version)s
debian-branch = deb/bookworm
debian-branch = deb/trixie

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.63 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 4fd569c..8b67a52 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,7 +1,7 @@
[package]
name = "gt-tool"
version = "2.2.0"
-edition = "2024"
+edition = "2021"
[dependencies]
clap = { version = "4.0.7", features = ["derive", "env"] }

View File

@@ -1,39 +0,0 @@
From: Robert Garrett <robertgarrett404@gmail.com>
Date: Fri, 4 Jul 2025 09:36:52 -0500
Subject: Lift the empty-body string outside the let-if
The if-else block that selects between the body of the Release or a
placeholder is returning references to variables that only exist
*inside* the body of the if-else blocks. Newer Rust versions seem to
understand the intent and do The Right Thing anyway (or they have some
other rule for how if-else block scopes work).
Manually lifting the variable to an outer scope resolves the problem.
---
src/structs/release.rs | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/structs/release.rs b/src/structs/release.rs
index 9ed537e..3c4a434 100644
--- a/src/structs/release.rs
+++ b/src/structs/release.rs
@@ -1,3 +1,5 @@
+use std::io::empty;
+
use colored::Colorize;
use serde::{Deserialize, Serialize};
@@ -27,10 +29,11 @@ impl Release {
let published = "Published:".bright_green();
let created = "Created:".green().dimmed();
let author = "Author:".blue();
+ let empty_body = String::from("(empty body)").dimmed();
let body = if !self.body.is_empty() {
- &self.body.white()
+ self.body.white()
} else {
- &String::from("(empty body)").dimmed()
+ empty_body
};
format!(

View File

@@ -1,2 +0,0 @@
0001-Rust-edition-downgrade-to-2021.patch
0002-Lift-the-empty-body-string-outside-the-let-if.patch

View File

@@ -4,9 +4,17 @@ 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,
#[arg(short = 'r', long = "repo", env = "GTTOOL_FQRN")]
pub repo: String,
pub gitea_url: Option<String>,
#[arg(short = 'o', long = "owner", env = "GTTOOL_OWNER")]
pub owner: Option<String>,
#[arg(short = 'r', long = "repo", env = "GTTOOL_REPO")]
pub repo: Option<String>,
#[arg(
short = 'p',
long = "project",
help = "Path to project (relative or absolute). Used to override configuration selection."
)]
pub project: Option<String>,
#[command(subcommand)]
pub command: Commands,

352
src/config.rs Normal file
View File

@@ -0,0 +1,352 @@
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)
// 3a. convert to PartialConfig
.and_then(PartialConfig::try_from)
// 3b. or default, if the table couldn't be found.
.or(Ok(PartialConfig::default()))
// 4. assemble a 2-tuple of PartialConfigs by...
.map(|proj| {
(
// 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)
.unwrap_or(PartialConfig::default()),
)
})
.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;
/// Scans properties out of a `toml::Table` to get a PartialConfig.
///
/// `Error::NoSuchProperty` is quietly ignored (mapped to `None`) since it
/// isn't an error in this context.
///
/// All other errors are propagated and should be treated as real failures.
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: Some(String::from("/no/such/project")),
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 {
project_path: Some(String::from("/some/other/path")),
gitea_url: Some(String::from("fake-url")),
..PartialConfig::default()
};
assert_eq!(load_result, expected);
Ok(())
}
// Ensure that trying to load files that don't exist simply get skipped over
// instead of causing a short-circuit exit or other bogus output.
#[test]
fn test_get_config_many_missing_files() -> Result<()> {
let search_paths = [
"./test_data/not_real_1.toml",
"./test_data/not_real_2.toml",
"./test_data/not_real_3.toml",
"./test_data/not_real_4.toml",
"./test_data/not_real_5.toml",
"./test_data/sample_config.toml",
"./test_data/not_real_6.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(())
}
}

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)]
@@ -20,6 +21,11 @@ pub(crate) async fn decode_client_error(response: reqwest::Response) -> Result<A
#[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
MissingRepoOwner,
MissingRepoName,
WrappedConfigErr(config::Error),
WrappedReqwestErr(reqwest::Error),
MissingAuthToken,
NoSuchFile, // for release attachment 'file exists' pre-check.
@@ -33,4 +39,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,4 +1,4 @@
use std::path;
use std::path::{self, PathBuf};
use gt_tool::cli::Args;
use gt_tool::structs::release::{CreateReleaseOption, Release};
@@ -12,6 +12,38 @@ use reqwest::header::ACCEPT;
async fn main() -> Result<(), gt_tool::Error> {
let args = Args::parse();
let project_path =
args.project
.map(PathBuf::from)
.unwrap_or(std::env::current_dir().map_err(|_e| {
gt_tool::Error::WrappedConfigErr(gt_tool::config::Error::CouldntReadFile)
})?);
let config = gt_tool::config::get_config(
project_path
.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)?;
let owner = args
.owner
.or(config.owner)
.ok_or(gt_tool::Error::MissingRepoOwner)?;
let repo = args
.repo
.or(config.repo)
.or_else(infer_repo)
.ok_or(gt_tool::Error::MissingRepoName)?;
let repo_fqrn = format!("{owner}/{repo}");
let mut headers = reqwest::header::HeaderMap::new();
headers.append(ACCEPT, header::HeaderValue::from_static("application/json"));
@@ -28,7 +60,7 @@ async fn main() -> Result<(), gt_tool::Error> {
match args.command {
gt_tool::cli::Commands::ListReleases => {
let releases =
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?;
// 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.
@@ -36,7 +68,7 @@ async fn main() -> Result<(), gt_tool::Error> {
releases.iter().rev().map(|release| release.colorized()),
String::from(""),
)
.map(|release| println!("{}", release))
.map(|release| println!("{release}"))
.fold((), |_, _| ());
}
gt_tool::cli::Commands::CreateRelease {
@@ -54,7 +86,7 @@ async fn main() -> Result<(), gt_tool::Error> {
tag_name,
target_commitish,
};
gt_tool::api::release::create_release(&client, &args.gitea_url, &args.repo, submission)
gt_tool::api::release::create_release(&client, &gitea_url, &repo_fqrn, submission)
.await?;
}
gt_tool::cli::Commands::UploadRelease {
@@ -75,7 +107,7 @@ 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) {
for file in &files {
@@ -93,11 +125,7 @@ async fn main() -> Result<(), gt_tool::Error> {
}
for file in files {
let _attach_desc = gt_tool::api::release_attachment::create_release_attachment(
&client,
&args.gitea_url,
&args.repo,
release.id,
file,
&client, &gitea_url, &repo_fqrn, release.id, file,
)
.await?;
}
@@ -144,3 +172,10 @@ fn match_release_by_tag(tag: &String, releases: Vec<Release>) -> Option<Release>
}
release
}
fn infer_repo() -> Option<String> {
let pwd = std::env::current_dir().ok()?;
let file_name = pwd.file_name()?;
let file_name_string = file_name.to_str()?;
Some(String::from(file_name_string))
}

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"