21 Commits

Author SHA1 Message Date
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
7 changed files with 311 additions and 1 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

@@ -10,6 +10,7 @@ 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.

283
src/config.rs Normal file
View File

@@ -0,0 +1,283 @@
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 {}
#[derive(Debug, Default)]
#[cfg_attr(test, derive(PartialEq))]
struct PartialConfig {
project_path: Option<String>,
gitea_url: Option<String>,
owner: Option<String>,
repo: Option<String>,
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
}
}
}
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(),
})
}
}
#[derive(Debug, Default)]
#[cfg_attr(test, derive(PartialEq))]
struct WholeFile {
all: PartialConfig,
project_overrides: Vec<PartialConfig>,
}
fn load_from_file(path: &str) -> Result<WholeFile> {
let res = std::fs::read_to_string(path);
match res {
Ok(s) => read_conf_str(s.as_str()),
Err(e) => {
eprintln!("->> file io err: {:?}", e);
Err(Error::CouldntReadFile)
}
}
}
fn read_conf_str(text: &str) -> Result<WholeFile> {
let mut whole = WholeFile::default();
let toml_val = text.parse::<Value>()?;
// The config file is one big table. If the string we decoded is
// some other toml::Value variant, it's not correct.
// Try getting it as a table, return Err(BadFormat) otherwise.
let cfg_table = toml_val.as_table().ok_or(Error::BadFormat)?;
// Get the global config out of the file
let table_all = get_table(cfg_table, "all")?;
whole.all = PartialConfig::try_from(table_all)?;
// Loop over the per-project configs, if any.
let per_project_keys = cfg_table
.keys()
.filter(|s| { // Discard the "[all]" table
*s != "all"
});
for path in per_project_keys {
let tab = get_table(cfg_table, path)?;
let part_cfg = PartialConfig::try_from(tab)?
.project_path(path.clone());
whole.project_overrides.push(part_cfg);
}
Ok(whole)
}
/// The outer value must be a Table so we can get the sub-table from it.
fn get_table<'outer>(outer: &'outer Table, table_name: impl ToString) -> Result<&'outer Table> {
Ok(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> (outer: &'outer Table, property: impl ToString) -> Result<Option<&'outer String>> {
let maybe_prop = get_property(outer, property);
match maybe_prop {
Ok(value) => Ok(Some(value)),
Err(e) => {
if let Error::NoSuchProperty = e {
return Ok(None);
} else {
return 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>(outer: &'outer Table, property: impl ToString) -> Result<&'outer 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::*;
// Util for generating a reference struct
fn gen_expected_struct() -> WholeFile {
WholeFile {
all: PartialConfig {
project_path: None,
gitea_url: Some(String::from("http://localhost:3000")),
owner: None,
repo: None,
token: Some(String::from("fake-token"))
},
project_overrides: vec![
PartialConfig {
project_path: Some(String::from("/home/robert/projects/gt-tool")),
gitea_url: None,
owner: Some(String::from("robert")),
repo: Some(String::from("gt-tool")),
token: None,
},
PartialConfig {
project_path: Some(String::from("/home/robert/projects/rcalc")),
gitea_url: None,
owner: Some(String::from("jamis")),
repo: Some(String::from("rcalc")),
token: None,
},
PartialConfig {
project_path: Some(String::from("/home/robert/projects/rcalc-builders")),
gitea_url: None,
owner: Some(String::from("jamis")),
repo: Some(String::from("rcalc")),
token: None,
},
],
}
}
#[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 read_config_string_ok() -> Result<()> {
let fx_sample_config_string = include_str!("../test_data/sample_config.toml");
let fx_expected_struct = gen_expected_struct();
let conf = read_conf_str(fx_sample_config_string)?;
assert_eq!(conf, fx_expected_struct);
Ok(())
}
/* TODO: Improve semantics around reading an empty string
An empty config string will result in Error::NoSuchTable when "[all]"
is retrieved. But this will *also* happen when other configs are present,
but "[all]" isn't. Do I treat these as valid configurations, using some
hard-coded default as the fallback? Or do I reject configs that don't have
an all-table?
*/
#[test]
fn read_config_string_empty() {
let fx_sample_cfg = "";
let conf = read_conf_str(fx_sample_cfg);
assert!(conf.is_err());
}
#[test]
// File exists and has valid configuration.
fn load_from_file_ok() -> Result<()> {
let conf = load_from_file("test_data/sample_config.toml")?;
assert_eq!(conf, gen_expected_struct());
Ok(())
}
#[test]
// File does not exist.
fn load_from_file_missing() -> Result<()> {
let res = load_from_file("test_data/doesnt_exist.toml");
let err = res.unwrap_err();
assert_eq!(err, Error::CouldntReadFile);
Ok(())
}
#[test]
// File exists but has garbage inside.
// TODO: This bumps against the same semantic issue as the todo note on
// the 'read_config_string_empty' test
fn load_from_file_bad() {
let res = load_from_file("test_data/missing_all_table.toml");
assert!(res.is_err());
}
}

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)]

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"