Skip to content
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:

strategy:
matrix:
rust_version: [stable, "1.53.0"]
rust_version: [stable, "1.58.0"]

steps:
- uses: actions/checkout@v2
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Foreman Changelog

## Unreleased
- Errors when duplicate tools are defined in `foreman.toml` ([#65](https://github.com/Roblox/foreman/pull/65))

## 1.0.6 (2022-09-28)

- Support `macos-aarch64` as an artifact name for Apple Silicon (arm64) binaries ([#60](https://github.com/Roblox/foreman/pull/60))
Expand Down
120 changes: 115 additions & 5 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
use std::{collections::BTreeMap, env, fmt};
use std::{
collections::BTreeMap,
env, fmt,
ops::{Deref, DerefMut},
};

use semver::VersionReq;
use serde::{Deserialize, Serialize};
use serde::{
de::{self, MapAccess, Visitor},
Deserialize, Serialize,
};

use crate::{
ci_string::CiString, error::ForemanError, fs, paths::ForemanPaths, tool_provider::Provider,
Expand Down Expand Up @@ -67,20 +74,43 @@ impl fmt::Display for ToolSpec {
}
}

#[derive(Debug, Serialize)]
pub struct ConfigFileTools(BTreeMap<String, ToolSpec>);

impl ConfigFileTools {
pub fn new() -> ConfigFileTools {
Self(BTreeMap::new())
}
}

impl Deref for ConfigFileTools {
type Target = BTreeMap<String, ToolSpec>;

fn deref(&self) -> &Self::Target {
&self.0
}
}

impl DerefMut for ConfigFileTools {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ConfigFile {
pub tools: BTreeMap<String, ToolSpec>,
pub tools: ConfigFileTools,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want, I think a neat tiny little improvement would be to make this field private and add a public method like pub fn tools(&self) -> impl Iterator<Item = &(String, ToolSpec)>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since some external files (like src/main.rs) want to perform methods like tools.get, I think it would probably be best to just do

pub fn tools(&self) -> &HashMap<String, ToolSpec> { &self.tools.0 }

which would leave us to have to create a tools_mut as well (as ConfigFile::fill_from needs it), or we could leave it the current way it is (implement Deref and DerefMut).

}

impl ConfigFile {
pub fn new() -> Self {
Self {
tools: BTreeMap::new(),
tools: ConfigFileTools::new(),
}
}

fn fill_from(&mut self, other: ConfigFile) {
for (tool_name, tool_source) in other.tools {
for (tool_name, tool_source) in other.tools.0 {
self.tools.entry(tool_name).or_insert(tool_source);
}
}
Expand Down Expand Up @@ -141,6 +171,48 @@ impl fmt::Display for ConfigFile {
}
}

struct ConfigFileVisitor;

impl<'de> Visitor<'de> for ConfigFileVisitor {
type Value = ConfigFileTools;

fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a map with non-duplicate keys")
}

fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut tools = BTreeMap::new();

while let Some((key, value)) = map.next_entry()? {
if tools.contains_key(&key) {
// item already existed inside the config
// throw an error as this is unlikely to be the users intention
return Err(de::Error::custom(format!(
"duplicate tool name `{key}` found"
)));
}

tools.insert(key, value);
}

Ok(ConfigFileTools(tools))
}
}

impl<'de> Deserialize<'de> for ConfigFileTools {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let tools = deserializer.deserialize_map(ConfigFileVisitor)?;

Ok(tools)
}
}

#[cfg(test)]
mod test {
use super::*;
Expand Down Expand Up @@ -189,6 +261,44 @@ mod test {
.unwrap();
assert_eq!(gitlab, new_gitlab("user/repo", version("0.1.0")));
}

#[test]
fn duplicate_tools() {
let err = toml::from_str::<ConfigFileTools>(
r#"tool = { github = "user/repo", version = "0.1.0" }
tool = { github = "user2/repo2", version = "0.2.0" }"#,
)
.unwrap_err();

assert_eq!(
err.to_string(),
"duplicate tool name `tool` found at line 1 column 1"
);

let err = toml::from_str::<ConfigFileTools>(
r#"tool_a = { github = "user/a", version = "0.1.0" }
tool_b = { github = "user/b", version = "0.2.0" }
tool_a = { gitlab = "user/c", version = "0.3.0" }"#,
)
.unwrap_err();

assert_eq!(
err.to_string(),
"duplicate tool name `tool_a` found at line 1 column 1"
);

let err = toml::from_str::<ConfigFileTools>(
r#"tool_b = { github = "user/b", version = "0.1.0" }
tool_a = { github = "user/a", version = "0.2.0" }
tool_a = { gitlab = "user/c", version = "0.3.0" }"#,
)
.unwrap_err();

assert_eq!(
err.to_string(),
"duplicate tool name `tool_a` found at line 1 column 1"
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind adding a test case to document the issue you're having with the line/column number being always 1?

I would like to see something like these cases:

tool_a = { github = "user/a", version = "0.1.0" } tool_b = { github = "user/b", version = "0.1.0" } tool_a = { github = "user/c", version = "0.1.0" }
tool_b = { github = "user/b", version = "0.1.0" } tool_a = { github = "user/a", version = "0.1.0" } tool_a = { github = "user/c", version = "0.1.0" }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added these test cases now.

❯ cat foreman.toml [tools] # oops! selene = { source = "rojo-rbx/rojo", version = "=7.1.1" } selene = { source = "Kampfkarren/selene", version = "=0.19.1" } ❯ selene --version unable to parse Foreman configuration file (at ~/project/foreman.toml): duplicate tool name `selene` found for key `tools` at line 3 column 1 A Foreman configuration file looks like this: [tools] # list the tools you want to install under this header # each tool is on its own line, the tool name is on the left # side of `=` and the right side tells Foreman where to find # it and which version to download tool_name = { github = "user/repository-name", version = "1.0.0" } # tools hosted on gitlab follows the same structure, except # `github` is replaced with `gitlab` # Examples: stylua = { github = "JohnnyMorganz/StyLua", version = "0.11.3" } darklua = { gitlab = "seaofvoices/darklua", version = "0.7.0" }

So I think it's not really an issue, it's just that the line serde points to is where [tools] is in the toml, rather than where the duplicate tool is. Unsure if that is a big enough issue to warrant looking in to.

}

#[test]
Expand Down