From a23bdf3e34387cb6fee11afb2d45ae8de7269dbf Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sat, 5 Jul 2025 17:21:59 -0500 Subject: [PATCH 01/59] Make the README title singular It's not the "gt-tools" anymore, so maybe the README should match. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 37688c9..c92947e 100644 --- a/README.md +++ b/README.md @@ -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. From 8eacb510a26ff007da5bea3ee7be3126e0477ee3 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 6 Jul 2025 12:54:52 -0500 Subject: [PATCH 02/59] 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. --- .gitea/workflows/release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 404238c..79262b8 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 From 075a2ee9213857bce65958e0b6191a5b8a6e6927 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 6 Jul 2025 17:28:11 -0500 Subject: [PATCH 03/59] Scaffold the new config module --- src/config.rs | 19 +++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 20 insertions(+) create mode 100644 src/config.rs diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..5126e85 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,19 @@ + +pub type Result = core::result::Result; + +#[derive(Debug)] +pub enum Error {} + +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 {} + +#[cfg(test)] +mod tests { + use super::*; +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 70a11a4..580c45d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; pub mod api; pub mod cli; +pub mod config; pub mod structs; #[derive(Debug, Deserialize, Serialize)] From f6bab756446ad1cee4c99e8b23b27e8199fc1e7e Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 14 Jul 2025 21:25:26 -0500 Subject: [PATCH 04/59] 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. --- Cargo.toml | 1 + src/config.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4fd569c..5a0ea7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/config.rs b/src/config.rs index 5126e85..40c50b8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,21 @@ +use toml::{Value, value::Table}; pub type Result = core::result::Result; #[derive(Debug)] -pub enum Error {} +#[cfg_attr(test, derive(PartialEq))] +pub enum Error { + BadFormat, + NoSuchProperty, + NoSuchTable, + TomlWrap(toml::de::Error), +} + +impl From 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 { @@ -13,7 +26,43 @@ impl core::fmt::Display for Error{ impl std::error::Error for Error {} +/// 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: String) -> Result<&'outer String> { + let maybe_prop = outer.get(&property).ok_or(Error::NoSuchProperty)?; + if let Value::String(text) = maybe_prop { + Ok(text) + } else { + Err(Error::BadFormat) + } +} + #[cfg(test)] mod tests { - use super::*; -} \ No newline at end of file + use super::*; + + #[test] + fn read_single_prop() -> Result<()> { + let fx_input_str = "owner = \"dingus\""; + let fx_value = fx_input_str.parse::()?; + 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::()?; + 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(()) + } +} From b2b9c8b9d96abad13e7b157ceecbac4950ef8ce1 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Wed, 16 Jul 2025 19:16:43 -0500 Subject: [PATCH 05/59] Add a get-table util function --- src/config.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/config.rs b/src/config.rs index 40c50b8..93adfa5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,15 @@ impl core::fmt::Display for Error{ impl std::error::Error for Error {} +/// 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: String) -> Result<&'outer Table> { + Ok(outer + .get(&table_name) + .ok_or(Error::NoSuchTable)? + .as_table() + .ok_or(Error::BadFormat)?) +} + /// 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: String) -> Result<&'outer String> { @@ -39,6 +48,8 @@ fn get_property<'outer>(outer: &'outer Table, property: String) -> Result<&'oute #[cfg(test)] mod tests { + use toml::map::Map; + use super::*; #[test] @@ -65,4 +76,17 @@ mod tests { 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::()?; + let fx_value = fx_value.as_table().ok_or(Error::BadFormat)?; + let mut expected: Map = 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(()) + } } From 30d8bcc6de6ddcbc77b2641b155d7e335ca0fda2 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 09:57:49 -0500 Subject: [PATCH 06/59] 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. --- src/config.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config.rs b/src/config.rs index 93adfa5..80361da 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,9 +27,9 @@ impl core::fmt::Display for Error{ impl std::error::Error for Error {} /// 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: String) -> Result<&'outer Table> { +fn get_table<'outer>(outer: &'outer Table, table_name: impl ToString) -> Result<&'outer Table> { Ok(outer - .get(&table_name) + .get(&table_name.to_string()) .ok_or(Error::NoSuchTable)? .as_table() .ok_or(Error::BadFormat)?) @@ -37,8 +37,8 @@ fn get_table<'outer>(outer: &'outer Table, table_name: String) -> Result<&'outer /// 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: String) -> Result<&'outer String> { - let maybe_prop = outer.get(&property).ok_or(Error::NoSuchProperty)?; +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 { From d27bea2c4391fded42e770116d6a689b89ae121a Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 09:59:52 -0500 Subject: [PATCH 07/59] 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. --- src/config.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/config.rs b/src/config.rs index 80361da..e9106b4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,6 +35,23 @@ fn get_table<'outer>(outer: &'outer Table, table_name: impl ToString) -> Result< .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> { + 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> { From 213e0b4f4a1b2573fa36139d458f8b3be73dc27e Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 10:23:00 -0500 Subject: [PATCH 08/59] Add partial- and whole- config structs --- src/config.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/config.rs b/src/config.rs index e9106b4..677e49a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,24 @@ impl core::fmt::Display for Error{ impl std::error::Error for Error {} +#[derive(Debug, Default)] +#[cfg_attr(test, derive(PartialEq))] +struct PartialConfig { + project_path: Option, + gitea_url: Option, + owner: Option, + repo: Option, + token: Option, +} + +#[derive(Debug, Default)] +#[cfg_attr(test, derive(PartialEq))] +struct WholeFile { + all: PartialConfig, + project_overrides: Vec, +} + + /// 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 From 330985940f441cb6e2384e429309fb0dc6093c34 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 10:25:26 -0500 Subject: [PATCH 09/59] 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. --- src/config.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/config.rs b/src/config.rs index 677e49a..988101a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,6 +43,28 @@ struct WholeFile { project_overrides: Vec, } +fn lconf(text: &str) -> Result { + let mut whole = WholeFile::default(); + + let toml_val = text.parse::()?; + + // 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 + if let Some(section_all) = cfg_table.get("all") { + if let Some(table_all) = section_all.as_table() { + whole.all.gitea_url = get_maybe_property(&table_all, "gitea_url")?.cloned(); + whole.all.owner = get_maybe_property(&table_all, "owner")?.cloned(); + whole.all.repo = get_maybe_property(&table_all, "repo")?.cloned(); + whole.all.token = get_maybe_property(&table_all, "token")?.cloned(); + } + } + + 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> { @@ -124,4 +146,41 @@ mod tests { assert_eq!(res, &expected); Ok(()) } + + #[test] + fn read_config_string_ok() -> Result<()> { + let fx_sample_config_string = " +[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\" +"; + let fx_expected_struct = 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![], + }; + let conf = lconf(fx_sample_config_string)?; + println!(" ->> Test conf: {:?}", conf); + println!(" ->> Ref conf: {:?}", fx_expected_struct); + + assert_eq!(conf, fx_expected_struct); + Ok(()) + } } From 15593204e0241c4c142800ea079c28214214809b Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 10:28:17 -0500 Subject: [PATCH 10/59] 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. --- src/config.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 988101a..508768a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -174,7 +174,30 @@ repo = \"rcalc\" repo: None, token: Some(String::from("fake-token")) }, - project_overrides: vec![], + 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, + }, + + ], }; let conf = lconf(fx_sample_config_string)?; println!(" ->> Test conf: {:?}", conf); From fc1e20185ecd5aeb5e36b47db90f8df848fa9502 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 11:41:28 -0500 Subject: [PATCH 11/59] Finish `fn lconf()`. Project-specific vals load Loop over the keys (ignore the "all" one) and repeat the same property extraction process. --- src/config.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 508768a..1d3d63a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -62,7 +62,26 @@ fn lconf(text: &str) -> Result { whole.all.token = get_maybe_property(&table_all, "token")?.cloned(); } } - + + // 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 { + project_path: Some(path.clone()), + gitea_url: get_maybe_property(&tab, "gitea_url")?.cloned(), + owner: get_maybe_property(&tab, "owner")?.cloned(), + repo: get_maybe_property(&tab, "repo")?.cloned(), + token: get_maybe_property(&tab, "token")?.cloned(), + }; + whole.project_overrides.push(part_cfg); + } + println!(" ->> lconf - keys {:?}", cfg_table.keys().collect::>()); Ok(whole) } From 5ce20adf2e824d26a04e81dea9b3920944ee6a39 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 11:47:18 -0500 Subject: [PATCH 12/59] Create-and-assign struct to whole.all Minor refactor to make the "[all]" table read look like the per-project table reads. --- src/config.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/config.rs b/src/config.rs index 1d3d63a..d3dc827 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,10 +56,13 @@ fn lconf(text: &str) -> Result { // Get the global config out of the file if let Some(section_all) = cfg_table.get("all") { if let Some(table_all) = section_all.as_table() { - whole.all.gitea_url = get_maybe_property(&table_all, "gitea_url")?.cloned(); - whole.all.owner = get_maybe_property(&table_all, "owner")?.cloned(); - whole.all.repo = get_maybe_property(&table_all, "repo")?.cloned(); - whole.all.token = get_maybe_property(&table_all, "token")?.cloned(); + whole.all = PartialConfig { + project_path: None, // There is no global project path. That's nonsense. + gitea_url: get_maybe_property(&table_all, "gitea_url")?.cloned(), + owner: get_maybe_property(&table_all, "owner")?.cloned(), + repo: get_maybe_property(&table_all, "repo")?.cloned(), + token: get_maybe_property(&table_all, "token")?.cloned(), + }; } } From 28539f54ccb4c69fc6ce02de28ef34f2de7fddf4 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 11:49:44 -0500 Subject: [PATCH 13/59] 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. --- src/config.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/config.rs b/src/config.rs index d3dc827..51e1732 100644 --- a/src/config.rs +++ b/src/config.rs @@ -54,17 +54,14 @@ fn lconf(text: &str) -> Result { let cfg_table = toml_val.as_table().ok_or(Error::BadFormat)?; // Get the global config out of the file - if let Some(section_all) = cfg_table.get("all") { - if let Some(table_all) = section_all.as_table() { - whole.all = PartialConfig { - project_path: None, // There is no global project path. That's nonsense. - gitea_url: get_maybe_property(&table_all, "gitea_url")?.cloned(), - owner: get_maybe_property(&table_all, "owner")?.cloned(), - repo: get_maybe_property(&table_all, "repo")?.cloned(), - token: get_maybe_property(&table_all, "token")?.cloned(), - }; - } - } + let table_all = get_table(cfg_table, "all")?; + whole.all = PartialConfig { + project_path: None, // There is no global project path. That's nonsense. + gitea_url: get_maybe_property(&table_all, "gitea_url")?.cloned(), + owner: get_maybe_property(&table_all, "owner")?.cloned(), + repo: get_maybe_property(&table_all, "repo")?.cloned(), + token: get_maybe_property(&table_all, "token")?.cloned(), + }; // Loop over the per-project configs, if any. let per_project_keys = cfg_table From 626973d2bcbaf39bb28897aeda4fdf75d58cdeca Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 12:08:35 -0500 Subject: [PATCH 14/59] 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. --- src/config.rs | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/config.rs b/src/config.rs index 51e1732..87feb0f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,6 +36,22 @@ struct PartialConfig { token: Option, } +impl TryFrom<&Table> for PartialConfig { + type Error = crate::config::Error; + + fn try_from(value: &Table) -> Result { + 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 { @@ -55,13 +71,7 @@ fn lconf(text: &str) -> Result { // Get the global config out of the file let table_all = get_table(cfg_table, "all")?; - whole.all = PartialConfig { - project_path: None, // There is no global project path. That's nonsense. - gitea_url: get_maybe_property(&table_all, "gitea_url")?.cloned(), - owner: get_maybe_property(&table_all, "owner")?.cloned(), - repo: get_maybe_property(&table_all, "repo")?.cloned(), - token: get_maybe_property(&table_all, "token")?.cloned(), - }; + whole.all = PartialConfig::try_from(table_all)?; // Loop over the per-project configs, if any. let per_project_keys = cfg_table @@ -72,12 +82,10 @@ fn lconf(text: &str) -> Result { for path in per_project_keys { let tab = get_table(cfg_table, path)?; + let part_cfg = PartialConfig::try_from(tab)?; let part_cfg = PartialConfig { project_path: Some(path.clone()), - gitea_url: get_maybe_property(&tab, "gitea_url")?.cloned(), - owner: get_maybe_property(&tab, "owner")?.cloned(), - repo: get_maybe_property(&tab, "repo")?.cloned(), - token: get_maybe_property(&tab, "token")?.cloned(), + ..part_cfg }; whole.project_overrides.push(part_cfg); } From 277f638c60e10382316cf47bc450e4a1d03f7ff8 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 12:13:14 -0500 Subject: [PATCH 15/59] 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. --- src/config.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/config.rs b/src/config.rs index 87feb0f..6f785bd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,6 +36,18 @@ struct PartialConfig { token: Option, } +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; @@ -82,11 +94,8 @@ fn lconf(text: &str) -> Result { for path in per_project_keys { let tab = get_table(cfg_table, path)?; - let part_cfg = PartialConfig::try_from(tab)?; - let part_cfg = PartialConfig { - project_path: Some(path.clone()), - ..part_cfg - }; + let part_cfg = PartialConfig::try_from(tab)? + .project_path(path.clone()); whole.project_overrides.push(part_cfg); } println!(" ->> lconf - keys {:?}", cfg_table.keys().collect::>()); From cb314a8b4c7a127a3316af60948dbc453f360b1b Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 13:29:25 -0500 Subject: [PATCH 16/59] 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. --- src/config.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/config.rs b/src/config.rs index 6f785bd..f6df0c7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -242,4 +242,18 @@ repo = \"rcalc\" 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 = lconf(fx_sample_cfg); + assert!(conf.is_err()); + } } From 247c06dd9e757e600ae466a0024e50380c29f1c1 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 13:32:55 -0500 Subject: [PATCH 17/59] Rename the config-string-reading function --- src/config.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index f6df0c7..c8ace99 100644 --- a/src/config.rs +++ b/src/config.rs @@ -71,7 +71,7 @@ struct WholeFile { project_overrides: Vec, } -fn lconf(text: &str) -> Result { +fn read_conf_str(text: &str) -> Result { let mut whole = WholeFile::default(); let toml_val = text.parse::()?; @@ -235,7 +235,7 @@ repo = \"rcalc\" ], }; - let conf = lconf(fx_sample_config_string)?; + let conf = read_conf_str(fx_sample_config_string)?; println!(" ->> Test conf: {:?}", conf); println!(" ->> Ref conf: {:?}", fx_expected_struct); @@ -253,7 +253,7 @@ repo = \"rcalc\" #[test] fn read_config_string_empty() { let fx_sample_cfg = ""; - let conf = lconf(fx_sample_cfg); + let conf = read_conf_str(fx_sample_cfg); assert!(conf.is_err()); } } From 912a7283fd320b40245f94c99581005b2fa15614 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 13:59:53 -0500 Subject: [PATCH 18/59] 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. --- src/config.rs | 89 ++++++++++++++++-------------------- test_data/sample_config.toml | 15 ++++++ 2 files changed, 54 insertions(+), 50 deletions(-) create mode 100644 test_data/sample_config.toml diff --git a/src/config.rs b/src/config.rs index c8ace99..a61ac4b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -145,6 +145,43 @@ mod tests { 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\""; @@ -185,56 +222,8 @@ mod tests { #[test] fn read_config_string_ok() -> Result<()> { - let fx_sample_config_string = " -[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\" -"; - let fx_expected_struct = 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, - }, - - ], - }; + 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)?; println!(" ->> Test conf: {:?}", conf); println!(" ->> Ref conf: {:?}", fx_expected_struct); diff --git a/test_data/sample_config.toml b/test_data/sample_config.toml new file mode 100644 index 0000000..6c0f1d1 --- /dev/null +++ b/test_data/sample_config.toml @@ -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" From 551297f46bf3dd46020b64a6e850c4d5f0f57bfb Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 14:13:04 -0500 Subject: [PATCH 19/59] Remove some debug prints --- src/config.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index a61ac4b..d40096c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -98,7 +98,6 @@ fn read_conf_str(text: &str) -> Result { .project_path(path.clone()); whole.project_overrides.push(part_cfg); } - println!(" ->> lconf - keys {:?}", cfg_table.keys().collect::>()); Ok(whole) } @@ -225,8 +224,6 @@ mod tests { 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)?; - println!(" ->> Test conf: {:?}", conf); - println!(" ->> Ref conf: {:?}", fx_expected_struct); assert_eq!(conf, fx_expected_struct); Ok(()) From 246987fa6859439479e932adf4eed99b9e73a0d0 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 14:20:21 -0500 Subject: [PATCH 20/59] 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. --- src/config.rs | 30 ++++++++++++++++++++++++++++++ test_data/missing_all_table.toml | 5 +++++ 2 files changed, 35 insertions(+) create mode 100644 test_data/missing_all_table.toml diff --git a/src/config.rs b/src/config.rs index d40096c..08a184f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -71,6 +71,10 @@ struct WholeFile { project_overrides: Vec, } +fn load_from_file(path: &str) -> Result { + todo!(); +} + fn read_conf_str(text: &str) -> Result { let mut whole = WholeFile::default(); @@ -242,4 +246,30 @@ mod tests { 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, todo!("Need a new config::Error variant to indicate a missing config file")); + 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()); + } } diff --git a/test_data/missing_all_table.toml b/test_data/missing_all_table.toml new file mode 100644 index 0000000..c89da34 --- /dev/null +++ b/test_data/missing_all_table.toml @@ -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" + From b26a594cc868ff1f2d5659361429e05f94476722 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Thu, 17 Jul 2025 15:06:01 -0500 Subject: [PATCH 21/59] 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. --- src/config.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index 08a184f..1f611aa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,6 +8,7 @@ pub enum Error { BadFormat, NoSuchProperty, NoSuchTable, + CouldntReadFile, TomlWrap(toml::de::Error), } @@ -72,7 +73,14 @@ struct WholeFile { } fn load_from_file(path: &str) -> Result { - todo!(); + 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 { @@ -260,7 +268,7 @@ mod tests { fn load_from_file_missing() -> Result<()> { let res = load_from_file("test_data/doesnt_exist.toml"); let err = res.unwrap_err(); - assert_eq!(err, todo!("Need a new config::Error variant to indicate a missing config file")); + assert_eq!(err, Error::CouldntReadFile); Ok(()) } From 2b47460258b7fee05f8f674f5eaffddbb4a07b1f Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sat, 19 Jul 2025 20:52:37 -0500 Subject: [PATCH 22/59] "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. --- src/config.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/config.rs b/src/config.rs index 1f611aa..99f6071 100644 --- a/src/config.rs +++ b/src/config.rs @@ -47,6 +47,19 @@ impl PartialConfig { ..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 { From 2e2c54d538e3b67a78e8ae6396a218f45c1b8a15 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sat, 19 Jul 2025 21:02:58 -0500 Subject: [PATCH 23/59] 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. --- src/config.rs | 106 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 99f6071..f3a1939 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use toml::{Value, value::Table}; pub type Result = core::result::Result; @@ -27,9 +29,91 @@ impl core::fmt::Display for Error{ impl std::error::Error for Error {} +/// Retrieves the configuration values for the project located at a given path. +/// +/// Configs are read from files named "gt-tool.toml" in +/// - /etc +/// - each dir in $XDG_CONFIG_DIRS. +/// FIXME: Allow user-specified search paths. +pub fn get_config(project: &str) -> Result { + /* + 1. Find all search dirs + - anything in `$XDG_CONFIG_DIRS` and the `/etc` dir + - will require splitting on ":" + - Prefer user-specific configs, so take XDG_CONFIG_DIRS first. + - I can't know which are "more specific" in the XDG_CONFIG_DIRS var, so + I'll have to take it as-is. + 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 + */ + + + // Read env var `XDG_CONFIG_DIRS` and split on ":" to get highest-priority list + // TODO: Emit warning when paths aren't unicode + let xdg_var = std::env::var("XDG_CONFIG_DIRS") + .unwrap_or(String::from("")); + let xdg_iter = xdg_var.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. + let etc_iter = ["/etc"]; + + // glue on the "/etc" path + let path_iter = xdg_iter.chain(etc_iter); + let file_iter = path_iter + .map(|path_str| { + let mut path = PathBuf::from(path_str); + path.push("gt-tool.toml"); + path + }); + 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::()) // try convert to `toml::Value` + .filter_map(|res| res.ok()); // remove any failed parses + let config_iter = toml_iter + .map(|val| -> Result { + // 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 + let maybe_proj = 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)?, + )) + }) + // 5. Merge the PartialConfigs together, project-specific has higher priority + .and_then(|pair| { + Ok(pair.0.merge(pair.1)) + }); + maybe_proj + }) + .filter_map(|res| res.ok()) + .fold(PartialConfig::default(), |acc, inc|{ + acc.merge(inc) + }); + return Ok(config_iter); +} + #[derive(Debug, Default)] #[cfg_attr(test, derive(PartialEq))] -struct PartialConfig { +pub struct PartialConfig { project_path: Option, gitea_url: Option, owner: Option, @@ -293,4 +377,24 @@ mod tests { let res = load_from_file("test_data/missing_all_table.toml"); assert!(res.is_err()); } + + #[test] + // FIXME: Allow user-specified search paths, then update test to use them. + // This test can only work if there's a config file the program can find. + // Right now, that means a file called "gt-tool.toml" in + // 1. `/etc` + // 2. anything inside `$XDG_CONFIG_DIRS` + fn check_get_config() -> Result<()>{ + let load_conf = get_config("/home/robert/projects/gt-tool")?; + 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_conf, expected); + Ok(()) + } } From ed76fa67ff82403f4f45ec264d244f049fe21176 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 20 Jul 2025 10:20:39 -0500 Subject: [PATCH 24/59] 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. --- src/config.rs | 60 +++++++++++++++++++++++---------------------------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/src/config.rs b/src/config.rs index f3a1939..6285bbb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,20 +29,26 @@ impl core::fmt::Display for Error{ impl std::error::Error for Error {} -/// Retrieves the configuration values for the project located at a given path. +/// Searches through the files, `search_files`, for configuration related to a +/// project, `project`. /// -/// Configs are read from files named "gt-tool.toml" in -/// - /etc -/// - each dir in $XDG_CONFIG_DIRS. -/// FIXME: Allow user-specified search paths. -pub fn get_config(project: &str) -> Result { +/// 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 +) -> Result { /* - 1. Find all search dirs - - anything in `$XDG_CONFIG_DIRS` and the `/etc` dir - - will require splitting on ":" - - Prefer user-specific configs, so take XDG_CONFIG_DIRS first. - - I can't know which are "more specific" in the XDG_CONFIG_DIRS var, so - I'll have to take it as-is. + 1. Get conf search (from fn input) 2. Iterate config dirs 3. Try load toml::Value from file 4. Try-get proj-specific table @@ -52,26 +58,8 @@ pub fn get_config(project: &str) -> Result { */ - // Read env var `XDG_CONFIG_DIRS` and split on ":" to get highest-priority list - // TODO: Emit warning when paths aren't unicode - let xdg_var = std::env::var("XDG_CONFIG_DIRS") - .unwrap_or(String::from("")); - let xdg_iter = xdg_var.split(":"); + let file_iter = search_files; - // 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. - let etc_iter = ["/etc"]; - - // glue on the "/etc" path - let path_iter = xdg_iter.chain(etc_iter); - let file_iter = path_iter - .map(|path_str| { - let mut path = PathBuf::from(path_str); - path.push("gt-tool.toml"); - path - }); let toml_iter = file_iter .map(std::fs::read_to_string) // read text from file .filter_map(|res| res.ok()) // remove any error messages @@ -385,7 +373,13 @@ mod tests { // 1. `/etc` // 2. anything inside `$XDG_CONFIG_DIRS` fn check_get_config() -> Result<()>{ - let load_conf = get_config("/home/robert/projects/gt-tool")?; + 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")), @@ -394,7 +388,7 @@ mod tests { token: Some(String::from("fake-token")), }; - assert_eq!(load_conf, expected); + assert_eq!(load_result, expected); Ok(()) } } From cf9b37fe9946d9257f9583e063d15e50646456dc Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 20 Jul 2025 10:40:50 -0500 Subject: [PATCH 25/59] 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. --- src/config.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/config.rs b/src/config.rs index 6285bbb..2ed1d5a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,6 +29,36 @@ impl core::fmt::Display for Error{ 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 { + // 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::>() + .into_iter() +} + /// Searches through the files, `search_files`, for configuration related to a /// project, `project`. /// From 64215cefcc6fb592bf73f20c1f410e7cd08e5a1a Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 20 Jul 2025 10:47:32 -0500 Subject: [PATCH 26/59] Remove `WholeFile` struct & anything that uses it --- src/config.rs | 135 -------------------------------------------------- 1 file changed, 135 deletions(-) diff --git a/src/config.rs b/src/config.rs index 2ed1d5a..fd92d77 100644 --- a/src/config.rs +++ b/src/config.rs @@ -180,54 +180,6 @@ impl TryFrom<&Table> for PartialConfig { } -#[derive(Debug, Default)] -#[cfg_attr(test, derive(PartialEq))] -struct WholeFile { - all: PartialConfig, - project_overrides: Vec, -} - -fn load_from_file(path: &str) -> Result { - 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 { - let mut whole = WholeFile::default(); - - let toml_val = text.parse::()?; - - // 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 @@ -271,43 +223,6 @@ mod tests { 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\""; @@ -346,56 +261,6 @@ mod tests { 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()); - } - #[test] // FIXME: Allow user-specified search paths, then update test to use them. // This test can only work if there's a config file the program can find. From 6ca279de49d2328dda4a802b37b738aaba36fc3c Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 20 Jul 2025 10:51:03 -0500 Subject: [PATCH 27/59] Autoformat --- src/config.rs | 74 +++++++++++++++++++++++++-------------------------- 1 file changed, 36 insertions(+), 38 deletions(-) diff --git a/src/config.rs b/src/config.rs index fd92d77..676ced0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,11 +20,11 @@ impl From for Error { } } -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 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 {} @@ -33,7 +33,7 @@ impl std::error::Error for Error {} /// is a list of files named "gt-tool.toml" found in decreasingly specific /// configuration folders. /// -/// - any dirs listed in env var `$XDG_CONFIG_DIRS` +/// - 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 @@ -61,10 +61,10 @@ pub fn default_paths() -> impl Iterator { /// 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 @@ -73,28 +73,27 @@ pub fn default_paths() -> impl Iterator { /// 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 ( +pub fn get_config( project: &str, - search_files: impl Iterator + search_files: impl Iterator, ) -> Result { /* - 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 - */ - + 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 + .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::()) // try convert to `toml::Value` + .map(|toml_text| toml_text.parse::()) // try convert to `toml::Value` .filter_map(|res| res.ok()); // remove any failed parses let config_iter = toml_iter .map(|val| -> Result { @@ -117,15 +116,11 @@ pub fn get_config ( )) }) // 5. Merge the PartialConfigs together, project-specific has higher priority - .and_then(|pair| { - Ok(pair.0.merge(pair.1)) - }); + .and_then(|pair| Ok(pair.0.merge(pair.1))); maybe_proj }) .filter_map(|res| res.ok()) - .fold(PartialConfig::default(), |acc, inc|{ - acc.merge(inc) - }); + .fold(PartialConfig::default(), |acc, inc| acc.merge(inc)); return Ok(config_iter); } @@ -143,7 +138,7 @@ 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{ + fn project_path(self, path: impl ToString) -> Self { PartialConfig { project_path: Some(path.to_string()), ..self @@ -177,7 +172,6 @@ impl TryFrom<&Table> for PartialConfig { token: get_maybe_property(&value, "token")?.cloned(), }) } - } /// The outer value must be a Table so we can get the sub-table from it. @@ -189,10 +183,12 @@ fn get_table<'outer>(outer: &'outer Table, table_name: impl ToString) -> Result< .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> { +fn get_maybe_property<'outer>( + outer: &'outer Table, + property: impl ToString, +) -> Result> { let maybe_prop = get_property(outer, property); match maybe_prop { Ok(value) => Ok(Some(value)), @@ -209,7 +205,9 @@ fn get_maybe_property<'outer> (outer: &'outer Table, property: impl ToString) -> /// 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)?; + let maybe_prop = outer + .get(&property.to_string()) + .ok_or(Error::NoSuchProperty)?; if let Value::String(text) = maybe_prop { Ok(text) } else { @@ -254,7 +252,10 @@ mod tests { let fx_value = fx_input_str.parse::()?; let fx_value = fx_value.as_table().ok_or(Error::BadFormat)?; let mut expected: Map = Map::new(); - expected.insert(String::from("with_a_garbage"), Value::String(String::from("value"))); + 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); @@ -267,14 +268,11 @@ mod tests { // Right now, that means a file called "gt-tool.toml" in // 1. `/etc` // 2. anything inside `$XDG_CONFIG_DIRS` - fn check_get_config() -> Result<()>{ + fn check_get_config() -> 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 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")), From ce480306e0c71fa06cdb46993889b1435379d8f4 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 20 Jul 2025 10:56:57 -0500 Subject: [PATCH 28/59] Cargo clippy fixes --- src/config.rs | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/config.rs b/src/config.rs index 676ced0..51c6a3e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -103,7 +103,8 @@ pub fn get_config( let cfg_table = val.as_table().ok_or(Error::BadFormat)?; // 2. Get table - let maybe_proj = get_table(cfg_table, project) + + get_table(cfg_table, project) // 3. convert to PartialConfig .and_then(PartialConfig::try_from) // 4. assemble a 2-tuple of PartialConfigs by... @@ -115,13 +116,11 @@ pub fn get_config( get_table(cfg_table, "all").and_then(PartialConfig::try_from)?, )) }) - // 5. Merge the PartialConfigs together, project-specific has higher priority - .and_then(|pair| Ok(pair.0.merge(pair.1))); - maybe_proj + .map(|pair| pair.0.merge(pair.1)) }) .filter_map(|res| res.ok()) .fold(PartialConfig::default(), |acc, inc| acc.merge(inc)); - return Ok(config_iter); + Ok(config_iter) } #[derive(Debug, Default)] @@ -166,37 +165,34 @@ impl TryFrom<&Table> for PartialConfig { 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(), + 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>(outer: &'outer Table, table_name: impl ToString) -> Result<&'outer Table> { - Ok(outer +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)?) + .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> { +fn get_maybe_property(outer: &Table, property: impl ToString) -> Result> { 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); + Ok(None) } else { - return Err(e); + Err(e) } } } @@ -204,7 +200,7 @@ fn get_maybe_property<'outer>( /// 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> { +fn get_property(outer: &Table, property: impl ToString) -> Result<&String> { let maybe_prop = outer .get(&property.to_string()) .ok_or(Error::NoSuchProperty)?; From 4e9a5dd25bf84d35dbc5c16095435da330995543 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 20 Jul 2025 12:17:27 -0500 Subject: [PATCH 29/59] Delete a now-solved FIXME comment --- src/config.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/config.rs b/src/config.rs index 51c6a3e..585e703 100644 --- a/src/config.rs +++ b/src/config.rs @@ -127,10 +127,10 @@ pub fn get_config( #[cfg_attr(test, derive(PartialEq))] pub struct PartialConfig { project_path: Option, - gitea_url: Option, - owner: Option, - repo: Option, - token: Option, + pub gitea_url: Option, + pub owner: Option, + pub repo: Option, + pub token: Option, } impl PartialConfig { @@ -259,11 +259,6 @@ mod tests { } #[test] - // FIXME: Allow user-specified search paths, then update test to use them. - // This test can only work if there's a config file the program can find. - // Right now, that means a file called "gt-tool.toml" in - // 1. `/etc` - // 2. anything inside `$XDG_CONFIG_DIRS` fn check_get_config() -> Result<()> { let search_paths = ["./test_data/sample_config.toml"] .into_iter() From 63d0a868ec416bd3fde04e2ed47b62882a6ff92d Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 20 Jul 2025 12:32:45 -0500 Subject: [PATCH 30/59] 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. --- src/cli.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index ce281da..4b02858 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, #[arg(short = 'r', long = "repo", env = "GTTOOL_FQRN")] - pub repo: String, + pub repo: Option, #[command(subcommand)] pub command: Commands, From 3453f6431291ba0f76f488713b4b8fb273d40e52 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 20 Jul 2025 12:33:38 -0500 Subject: [PATCH 31/59] 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. --- src/lib.rs | 9 +++++++++ src/main.rs | 38 +++++++++++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 580c45d..d14ed47 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,9 @@ pub(crate) async fn decode_client_error(response: reqwest::Response) -> Result for crate::Error { } } +impl From for crate::Error { + fn from(value: crate::config::Error) -> Self { + Self::WrappedConfigErr(value) + } +} + type Result = core::result::Result; diff --git a/src/main.rs b/src/main.rs index 1c98e8b..711ef20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,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")); @@ -28,7 +56,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. @@ -54,7 +82,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 +103,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 { @@ -94,8 +122,8 @@ 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, + &gitea_url, + &repo_fqrn, release.id, file, ) From 5b8a09e9ca8f601ccfeacec368b7bbe4403a578a Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 20 Jul 2025 13:18:01 -0500 Subject: [PATCH 32/59] 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 --- src/config.rs | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 585e703..85b78ac 100644 --- a/src/config.rs +++ b/src/config.rs @@ -259,7 +259,7 @@ mod tests { } #[test] - fn check_get_config() -> Result<()> { + fn test_get_config_with_specific_match() -> Result<()> { let search_paths = ["./test_data/sample_config.toml"] .into_iter() .map(PathBuf::from); @@ -275,4 +275,39 @@ mod tests { 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(()) + } } From 73363718c3b3836ffef412accf302d249fdd89d0 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 20 Jul 2025 16:05:34 -0500 Subject: [PATCH 33/59] 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. --- src/config.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/config.rs b/src/config.rs index 85b78ac..1afdf26 100644 --- a/src/config.rs +++ b/src/config.rs @@ -310,4 +310,30 @@ mod tests { 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(()) + } } From 46d8618e749362ae251838c36e6df87304febcdf Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 20 Jul 2025 17:14:37 -0500 Subject: [PATCH 34/59] 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. --- src/config.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 1afdf26..a7b81c9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -285,7 +285,7 @@ mod tests { .map(PathBuf::from); let load_result = get_config("/no/such/project", search_paths)?; let expected = PartialConfig { - project_path: None, + project_path: Some(String::from("/no/such/project")), owner: None, repo: None, gitea_url: Some(String::from("http://localhost:3000")), @@ -304,6 +304,7 @@ mod tests { .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() }; From 56b0580a9a2438e6aebc236f88686ec41a1e273c Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 20 Jul 2025 17:23:44 -0500 Subject: [PATCH 35/59] 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. --- src/config.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/config.rs b/src/config.rs index a7b81c9..1a256ea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -161,6 +161,12 @@ impl PartialConfig { 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 { Ok(Self { // can't get table name because that key is gone by this point. From 13ef1d25eb619488f5e88847f3d0adf0622ff661 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 20 Jul 2025 17:32:11 -0500 Subject: [PATCH 36/59] 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. --- src/config.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 1a256ea..f3252ca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -105,8 +105,10 @@ pub fn get_config( // 2. Get table get_table(cfg_table, project) - // 3. convert to PartialConfig + // 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... .and_then(|proj| { Ok(( From 04dd333d724d78383b90d0b08331b0479ef20792 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 20 Jul 2025 17:33:55 -0500 Subject: [PATCH 37/59] Fix: use default "[all]" if one isn't present Same thing as the previous commit, but for the "[all]" table. --- src/config.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index f3252ca..dfe4f94 100644 --- a/src/config.rs +++ b/src/config.rs @@ -115,7 +115,9 @@ pub fn get_config( // 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)?, + get_table(cfg_table, "all") + .and_then(PartialConfig::try_from) + .unwrap_or(PartialConfig::default()), )) }) .map(|pair| pair.0.merge(pair.1)) From 0e3aa16e00a3a1f1343aa93de551db87ab41ed57 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Sun, 20 Jul 2025 17:36:49 -0500 Subject: [PATCH 38/59] Another autoformat --- src/config.rs | 12 +++++++----- src/lib.rs | 2 +- src/main.rs | 29 ++++++++++++++--------------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/config.rs b/src/config.rs index dfe4f94..afbf012 100644 --- a/src/config.rs +++ b/src/config.rs @@ -166,10 +166,10 @@ 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 { Ok(Self { @@ -315,8 +315,8 @@ mod tests { 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() + gitea_url: Some(String::from("fake-url")), + ..PartialConfig::default() }; assert_eq!(load_result, expected); Ok(()) @@ -334,7 +334,9 @@ mod tests { "./test_data/not_real_5.toml", "./test_data/sample_config.toml", "./test_data/not_real_6.toml", - ].into_iter().map(PathBuf::from); + ] + .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")), diff --git a/src/lib.rs b/src/lib.rs index d14ed47..25c0afd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,7 +20,7 @@ pub(crate) async fn decode_client_error(response: reqwest::Response) -> Result Result<(), gt_tool::Error> { // 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 - ))?; + .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() + 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)?; + 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 + let conf_fqrn = config + .owner .ok_or(gt_tool::Error::MissingRepoFRQN) - .and_then(| mut own| { + .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 + 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")); @@ -121,11 +124,7 @@ async fn main() -> Result<(), gt_tool::Error> { } for file in files { let _attach_desc = gt_tool::api::release_attachment::create_release_attachment( - &client, - &gitea_url, - &repo_fqrn, - release.id, - file, + &client, &gitea_url, &repo_fqrn, release.id, file, ) .await?; } From 0e814b86a1205d90a01ddac31dc50b46a87a42e4 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 10:52:20 -0500 Subject: [PATCH 39/59] Fix some clippy lints --- src/config.rs | 6 +++--- src/main.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/config.rs b/src/config.rs index afbf012..0f6ed18 100644 --- a/src/config.rs +++ b/src/config.rs @@ -110,15 +110,15 @@ pub fn get_config( // 3b. or default, if the table couldn't be found. .or(Ok(PartialConfig::default())) // 4. assemble a 2-tuple of PartialConfigs by... - .and_then(|proj| { - Ok(( + .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)) }) diff --git a/src/main.rs b/src/main.rs index dfc1b02..1f5ac8f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,7 +34,7 @@ async fn main() -> Result<(), gt_tool::Error> { .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('/'); own.push_str(&repo); Ok(own) }); @@ -67,7 +67,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 { From 8cfc6605c95a9262f53427956ddd3082103843c8 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 10:56:26 -0500 Subject: [PATCH 40/59] 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. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 5a0ea7c..66bde80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gt-tool" -version = "2.2.0" +version = "3.0.0-alpha.1" edition = "2024" [dependencies] From fc0d1b569c8b343d91278385df1a488778951bf1 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 11:56:20 -0500 Subject: [PATCH 41/59] Add a project path CLI option --- src/cli.rs | 6 ++++++ src/main.rs | 13 +++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 4b02858..bb0923a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,6 +7,12 @@ pub struct Args { pub gitea_url: Option, #[arg(short = 'r', long = "repo", env = "GTTOOL_FQRN")] pub repo: Option, + #[arg( + short = 'p', + long = "project", + help = "Path to project (relative or absolute). Used to select configuration." + )] + pub project: Option, #[command(subcommand)] pub command: Commands, diff --git a/src/main.rs b/src/main.rs index 1f5ac8f..5a5b82a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use std::path; +use std::path::{self, PathBuf}; use gt_tool::cli::Args; use gt_tool::structs::release::{CreateReleaseOption, Release}; @@ -14,10 +14,15 @@ async fn main() -> Result<(), gt_tool::Error> { // 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 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( - pwd.to_str() + project_path + .to_str() .expect("I assumed the path can be UTF-8, but that didn't work out..."), gt_tool::config::default_paths(), )?; From 1a619d7bb4cca596321a4f8d7dfd99caffbe0012 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 14:01:06 -0500 Subject: [PATCH 42/59] 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. --- README.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c92947e..ec3c5e5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ CLI tools for interacting with the Gitea API. Use interactively to talk to your ## Usage ```txt -Usage: gt-tools --url --repo +Usage: gt-tool [OPTIONS] Commands: list-releases @@ -14,10 +14,11 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -u, --url [env: GTTOOL_GITEA_URL=] - -r, --repo [env: GTTOOL_FQRN=] - -h, --help Print help - -V, --version Print version + -u, --url [env: GTTOOL_GITEA_URL=] + -r, --repo [env: GTTOOL_FQRN=] + -p, --project Path to project (relative or absolute). Used to select configuration. + -h, --help Print help + -V, --version Print version ``` ### Authentication @@ -40,6 +41,12 @@ The repository name must be provided with `--repo` or `-u` on the command line, E.g.: `--repo "go-gitea/gitea"` would name the Gitea repo belonging to the go-gitea organization. +### `` + +Override the default (current-directory) project name when searching through the config files for this project's settings. + +See [configuration](#configuration) for details on format and file locations. + ### ``: One of these, defaults to `help`: From c1019afa7a342fa00d4b01b849b72d55d31ce3d3 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 14:01:53 -0500 Subject: [PATCH 43/59] Write configuration guide in the README --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/README.md b/README.md index ec3c5e5..519e952 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,57 @@ 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. + +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" +``` + +Notice that there is an "owner" *and* "repo" key here, while the CLI args only have a repo option. This is a left-over inconsistency caused from the earlier design expecting to get `"/"` together for use in the HTTP-API call. I'll fix it later. + +Some may apply to all projects. For this, one 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" +# no `repo = ` section because that must be project specific. +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" +``` + +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. First, each dir in `$XDG_CONFIG_DIRS` is scanned for a `gt-tool.toml` file. 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 From 7c0966be3055bb68978178a96aa306e10b6ea160 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 14:20:42 -0500 Subject: [PATCH 44/59] Split the owner and repo args apart in CLI parser --- src/cli.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli.rs b/src/cli.rs index bb0923a..b5a25b3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,7 +5,9 @@ use clap::{Parser, Subcommand}; pub struct Args { #[arg(short = 'u', long = "url", env = "GTTOOL_GITEA_URL")] pub gitea_url: Option, - #[arg(short = 'r', long = "repo", env = "GTTOOL_FQRN")] + #[arg(short = 'o', long = "owner", env = "GTTOOL_OWNER")] + pub owner: Option, + #[arg(short = 'r', long = "repo", env = "GTTOOL_REPO")] pub repo: Option, #[arg( short = 'p', From da8f008f1a4e342a98cf213adf85128346e469bf Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 14:48:30 -0500 Subject: [PATCH 45/59] 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. --- src/lib.rs | 2 ++ src/main.rs | 35 ++++++++++++++++++----------------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 25c0afd..e20b41a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,8 @@ 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, diff --git a/src/main.rs b/src/main.rs index 5a5b82a..0193b29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,8 +12,6 @@ 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 project_path = args.project .map(PathBuf::from) @@ -32,21 +30,17 @@ async fn main() -> Result<(), gt_tool::Error> { .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('/'); - own.push_str(&repo); - Ok(own) - }); - let repo_fqrn = args - .repo - .ok_or(gt_tool::Error::MissingRepoFRQN) - .or(conf_fqrn)?; + + 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 = String::from(format!("{owner}/{repo}")); let mut headers = reqwest::header::HeaderMap::new(); headers.append(ACCEPT, header::HeaderValue::from_static("application/json")); @@ -176,3 +170,10 @@ fn match_release_by_tag(tag: &String, releases: Vec) -> Option } release } + +fn infer_repo() -> Option { + 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)) +} From e954a2b09a261b890c79bd5b91c297b6803108df Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 14:54:16 -0500 Subject: [PATCH 46/59] Drop notice about CLI not having "repo" & "owner" --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 519e952..ceb5707 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,6 @@ repo = "gt-tool" token = "fake-token" ``` -Notice that there is an "owner" *and* "repo" key here, while the CLI args only have a repo option. This is a left-over inconsistency caused from the earlier design expecting to get `"/"` together for use in the HTTP-API call. I'll fix it later. - Some may apply to all projects. For this, one can use the special `[all]` table. ```toml From 333636b5246778a0dc113b94fd3d98fd80963fc2 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 14:57:18 -0500 Subject: [PATCH 47/59] Revise help text for CLI "--project" arg --- src/cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.rs b/src/cli.rs index b5a25b3..c8fffe1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -12,7 +12,7 @@ pub struct Args { #[arg( short = 'p', long = "project", - help = "Path to project (relative or absolute). Used to select configuration." + help = "Path to project (relative or absolute). Used to override configuration selection." )] pub project: Option, From 5bd2862498e2fa895ea4dadf39b9e465d9f11043 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 15:47:23 -0500 Subject: [PATCH 48/59] Update usage printout --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ceb5707..524120f 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ Commands: Options: -u, --url [env: GTTOOL_GITEA_URL=] - -r, --repo [env: GTTOOL_FQRN=] - -p, --project Path to project (relative or absolute). Used to select configuration. + -o, --owner [env: GTTOOL_OWNER=] + -r, --repo [env: GTTOOL_REPO=] + -p, --project Path to project (relative or absolute). Used to override configuration selection. -h, --help Print help -V, --version Print version ``` From 0e7bca80cbf0705757a9a35d5431c8baebc780da Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 15:53:46 -0500 Subject: [PATCH 49/59] 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. --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 524120f..b64aa53 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,24 @@ Options: -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`. From 3315c18ed20662e04ef148c974b66397a1f2f901 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 16:09:12 -0500 Subject: [PATCH 50/59] 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. --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b64aa53..830f716 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,16 @@ Likewise, the "repo" is what ever the Gitea instance thinks it's called -- which ### 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` + +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. + +For details, see Gitea's documentation on [token scopes](https://docs.gitea.com/development/oauth2-provider#scopes). ### ``: From d34eda77dceb10ef9f74575faaac5300d4def989 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 16:12:56 -0500 Subject: [PATCH 51/59] Delete the old CLI option sections --- README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/README.md b/README.md index 830f716..d235a52 100644 --- a/README.md +++ b/README.md @@ -53,18 +53,6 @@ Whether or not it is required depends on how your Gitea instance and the reposit For details, see Gitea's documentation on [token scopes](https://docs.gitea.com/development/oauth2-provider#scopes). -### ``: - -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. - -E.g.: Using the Gitea org's demo instance, it would be: `--url "https://demo.gitea.com/"` - -### ``: - -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 `/`, 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. - -E.g.: `--repo "go-gitea/gitea"` would name the Gitea repo belonging to the go-gitea organization. - ### `` Override the default (current-directory) project name when searching through the config files for this project's settings. From 4b9257a9a75edf0bfedaf332aa916d177fbc80dc Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 16:13:46 -0500 Subject: [PATCH 52/59] Rename remaining CLI arg sections The previous text was pretty ugly and not particularly useful to catch the eye when looking for relevant sections. --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d235a52..d01c20e 100644 --- a/README.md +++ b/README.md @@ -53,15 +53,13 @@ Whether or not it is required depends on how your Gitea instance and the reposit For details, see Gitea's documentation on [token scopes](https://docs.gitea.com/development/oauth2-provider#scopes). -### `` +### The `--project` option Override the default (current-directory) project name when searching through the config files for this project's settings. See [configuration](#configuration) for details on format and file locations. -### ``: - -One of these, defaults to `help`: +### Commands: | Command | Description | |-|-| From b952e40060f9ae16406a809d33d13529b8391bc4 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 16:14:47 -0500 Subject: [PATCH 53/59] 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. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d01c20e..a1a4fd2 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ For details, see Gitea's documentation on [token scopes](https://docs.gitea.com/ ### The `--project` option -Override the default (current-directory) project name when searching through the config files for this project's settings. +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. See [configuration](#configuration) for details on format and file locations. From b290a8b1d6e619e6b1f97a30f795e17f186f0ccd Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 16:18:44 -0500 Subject: [PATCH 54/59] Drop the "no-repo" comment in TOML example It's not relevant to the example and might confuse readers as a result. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index a1a4fd2..ed14437 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,6 @@ Since the more-specific settings are preferred, these can be combined to have an [all] gitea_url = "https://demo.gitea.com/" owner = "robert" -# no `repo = ` section because that must be project specific. token = "fake-token" # Override Gitea target so I can test my uploads privately. From 250140a954dae43e5897417b3fc61081d1cc6552 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 16:20:48 -0500 Subject: [PATCH 55/59] Rephrase the all-projects setting introduction --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ed14437..dace564 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ repo = "gt-tool" token = "fake-token" ``` -Some may apply to all projects. For this, one can use the special `[all]` table. +Sometimes one may want to apply a setting to all projects. For this, they can use the special `[all]` table. ```toml [all] From 00edaf87ce1a2895f153d4fd7bca3de2e13a2873 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 16:21:10 -0500 Subject: [PATCH 56/59] 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. --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dace564..7ed9a36 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ Instead of specifying everything on the command line every single run, some TOML > 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 @@ -105,7 +107,12 @@ gitea_url = "http://localhost:3000" repo = "gt-tool" ``` -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. First, each dir in `$XDG_CONFIG_DIRS` is scanned for a `gt-tool.toml` file. Then, `/etc/gt-tool.toml`. +### 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`. From 7f35b808e590a1ff5973548f0b4417665704a2a9 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 16:37:14 -0500 Subject: [PATCH 57/59] Lint and format --- src/main.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0193b29..10e505e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,16 +31,18 @@ async fn main() -> Result<(), gt_tool::Error> { .or(config.gitea_url) .ok_or(gt_tool::Error::MissingGiteaUrl)?; - let owner = args.owner + let owner = args + .owner .or(config.owner) .ok_or(gt_tool::Error::MissingRepoOwner)?; - let repo = args.repo + let repo = args + .repo .or(config.repo) .or_else(infer_repo) .ok_or(gt_tool::Error::MissingRepoName)?; - let repo_fqrn = String::from(format!("{owner}/{repo}")); + let repo_fqrn = format!("{owner}/{repo}"); let mut headers = reqwest::header::HeaderMap::new(); headers.append(ACCEPT, header::HeaderValue::from_static("application/json")); From 144fba53733144eedc7373fe6c53a1bc80c5b783 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Mon, 21 Jul 2025 16:37:37 -0500 Subject: [PATCH 58/59] Bump crate version to v3.0.0 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 66bde80..39341a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gt-tool" -version = "3.0.0-alpha.1" +version = "3.0.0" edition = "2024" [dependencies] From 641efc3bf7a2376af1dcbb2cf156f71b3f1ac528 Mon Sep 17 00:00:00 2001 From: Robert Garrett Date: Tue, 22 Jul 2025 09:41:54 -0500 Subject: [PATCH 59/59] Update automation workflow with new CLI args --- .gitea/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 79262b8..f7001e3 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -22,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)