use anyhow::{Context, bail};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::warn;
pub const SCHEMA_VERSION: u32 = 1;
pub const GEAR_DIR_NAME: &str = "gear.d";
pub fn gear_dir(env_dir: &Path) -> PathBuf {
env_dir.join(GEAR_DIR_NAME)
}
pub fn gear_filename(cookbook_name: &str) -> String {
format!("cookbook-{cookbook_name}.json")
}
fn deserialize_supported_version<'de, D: serde::Deserializer<'de>>(de: D) -> Result<u32, D::Error> {
let v = u32::deserialize(de)?;
if v == SCHEMA_VERSION {
Ok(v)
} else {
Err(serde::de::Error::custom(format!(
"unsupported gear schema version {v}; this build of enwiro-sdk handles version {SCHEMA_VERSION}"
)))
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct GearFileData {
#[serde(deserialize_with = "deserialize_supported_version")]
pub version: u32,
pub gear: HashMap<String, Gear>,
}
pub struct GearFile {
pub path: PathBuf,
pub data: GearFileData,
}
impl GearFile {
pub fn from_path(path: &Path) -> anyhow::Result<Self> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("Could not read {}", path.display()))?;
let data: GearFileData = serde_json::from_str(&contents)
.with_context(|| format!("Could not parse {}", path.display()))?;
Ok(Self {
path: path.to_path_buf(),
data,
})
}
pub fn label(&self) -> String {
self.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| self.path.display().to_string())
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Gear {
pub description: String,
#[serde(default)]
pub web: HashMap<String, WebEntry>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WebEntry {
pub description: String,
pub url: String,
}
pub struct LoadedGear {
gear: HashMap<String, Gear>,
}
impl LoadedGear {
pub fn from_env_dir(env_dir: &Path) -> anyhow::Result<Self> {
let dir = gear_dir(env_dir);
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self {
gear: HashMap::new(),
});
}
Err(err) => {
return Err(err)
.with_context(|| format!("Could not read gear directory {}", dir.display()));
}
};
let mut paths: Vec<_> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "json"))
.collect();
paths.sort();
let mut merged: HashMap<String, Gear> = HashMap::new();
let mut sources: HashMap<String, String> = HashMap::new();
for path in paths {
let file = match GearFile::from_path(&path) {
Ok(f) => f,
Err(err) => {
warn!(error = %format!("{err:#}"), "Skipping gear file");
continue;
}
};
let label = file.label();
for (name, gear) in file.data.gear {
if let Some(prior) = sources.get(&name) {
bail!(
"Gear name '{}' is defined in both {} and {}",
name,
prior,
label
);
}
sources.insert(name.clone(), label.clone());
merged.insert(name, gear);
}
}
Ok(Self { gear: merged })
}
pub fn into_map(self) -> HashMap<String, Gear> {
self.gear
}
pub fn get(&self, name: &str) -> Option<&Gear> {
self.gear.get(name)
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &Gear)> {
self.gear.iter()
}
pub fn is_empty(&self) -> bool {
self.gear.is_empty()
}
}
#[cfg(test)]
mod tests {
mod schema {
use super::super::{Gear, GearFileData, WebEntry};
use rstest::rstest;
fn valid_full_schema_json() -> &'static str {
r#"{
"version": 1,
"gear": {
"pr": {
"description": "Pull request #309 on kantord/enwiro",
"web": {
"page": {
"description": "Open the PR page",
"url": "https://github.com/kantord/enwiro/pull/309"
}
}
}
}
}"#
}
#[test]
fn deserializes_valid_full_schema_into_gear_file() {
let parsed: GearFileData = serde_json::from_str(valid_full_schema_json())
.expect("valid schema must deserialize successfully");
assert_eq!(parsed.version, 1, "version field must round-trip as 1");
assert_eq!(
parsed.gear.len(),
1,
"expected exactly one entry in the gear map"
);
let pr_gear: &Gear = parsed.gear.get("pr").expect("`pr` gear must be present");
assert_eq!(pr_gear.description, "Pull request #309 on kantord/enwiro");
assert_eq!(
pr_gear.web.len(),
1,
"expected exactly one entry in the web map"
);
let page: &WebEntry = pr_gear
.web
.get("page")
.expect("`page` web entry must be present");
assert_eq!(page.description, "Open the PR page");
assert_eq!(page.url, "https://github.com/kantord/enwiro/pull/309");
}
#[rstest]
#[case::version_missing(r#"{ "gear": { "pr": { "description": "x", "web": {} } } }"#)]
#[case::gear_missing(r#"{ "version": 1 }"#)]
#[case::gear_entry_no_description(r#"{ "version": 1, "gear": { "pr": { "web": {} } } }"#)]
#[case::web_entry_no_url(
r#"{ "version": 1, "gear": { "pr": { "description": "x",
"web": { "page": { "description": "Open the page" } } } } }"#
)]
#[case::web_entry_no_description(
r#"{ "version": 1, "gear": { "pr": { "description": "x",
"web": { "page": { "url": "https://example.com" } } } } }"#
)]
#[case::unknown_top_level_field(r#"{ "version": 1, "gear": {}, "extra_top_level": true }"#)]
#[case::unknown_field_in_gear_entry(
r#"{ "version": 1, "gear": { "pr": {
"description": "x", "web": {}, "rogue": 42 } } }"#
)]
#[case::unknown_field_in_web_entry(
r#"{ "version": 1, "gear": { "pr": { "description": "x",
"web": { "page": { "description": "Open the page",
"url": "https://example.com", "rogue": "value" } } } } }"#
)]
#[case::unsupported_schema_version(r#"{ "version": 999, "gear": {} }"#)]
fn rejects_invalid_schema(#[case] json: &str) {
let result: Result<GearFileData, _> = serde_json::from_str(json);
assert!(result.is_err(), "expected rejection, got: {result:?}");
}
#[test]
fn gear_entry_without_web_field_succeeds_with_empty_web_map() {
let json = r#"{
"version": 1,
"gear": {
"cli-only": {
"description": "A gear that has no web entries yet"
}
}
}"#;
let parsed: GearFileData = serde_json::from_str(json)
.expect("missing `web` should default to empty map, not error");
let cli_only = parsed
.gear
.get("cli-only")
.expect("`cli-only` gear must be present");
assert!(
cli_only.web.is_empty(),
"absent `web` field must default to empty map, got {} entries",
cli_only.web.len()
);
}
}
mod loaded_gear {
use super::super::{LoadedGear, SCHEMA_VERSION, gear_dir};
use std::fs;
fn write_gear_file(env_dir: &std::path::Path, file_name: &str, gears_json: &str) {
let dir = gear_dir(env_dir);
fs::create_dir_all(&dir).unwrap();
let body = format!(r#"{{"version": {SCHEMA_VERSION}, "gear": {gears_json}}}"#);
fs::write(dir.join(file_name), body).unwrap();
}
fn one_gear_json(name: &str, description: &str) -> String {
format!(
r#"{{
"{name}": {{
"description": "{description}",
"web": {{
"page": {{
"description": "Open it",
"url": "https://example.com/{name}"
}}
}}
}}
}}"#
)
}
#[test]
fn returns_empty_when_directory_missing() {
let tmp = tempfile::tempdir().unwrap();
let result = LoadedGear::from_env_dir(tmp.path())
.map(LoadedGear::into_map)
.unwrap();
assert!(result.is_empty(), "missing gear.d/ must yield empty map");
}
#[test]
fn loads_single_file() {
let tmp = tempfile::tempdir().unwrap();
write_gear_file(
tmp.path(),
"cookbook-github.json",
&one_gear_json("pr", "PR #1"),
);
let result = LoadedGear::from_env_dir(tmp.path())
.map(LoadedGear::into_map)
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result["pr"].description, "PR #1");
assert_eq!(result["pr"].web["page"].url, "https://example.com/pr");
}
#[test]
fn merges_distinct_gears_across_files() {
let tmp = tempfile::tempdir().unwrap();
write_gear_file(
tmp.path(),
"cookbook-github.json",
&one_gear_json("pr", "PR"),
);
write_gear_file(tmp.path(), "user.json", &one_gear_json("notes", "Notes"));
let result = LoadedGear::from_env_dir(tmp.path())
.map(LoadedGear::into_map)
.unwrap();
assert_eq!(result.len(), 2);
assert!(result.contains_key("pr"));
assert!(result.contains_key("notes"));
}
#[test]
fn errors_on_gear_name_collision_across_files() {
let tmp = tempfile::tempdir().unwrap();
write_gear_file(
tmp.path(),
"a-cookbook.json",
&one_gear_json("pr", "from a"),
);
write_gear_file(
tmp.path(),
"z-cookbook.json",
&one_gear_json("pr", "from z"),
);
let err = LoadedGear::from_env_dir(tmp.path())
.map(LoadedGear::into_map)
.expect_err("collision must be an error");
let msg = format!("{err:#}");
assert!(
msg.contains("'pr'"),
"error must name the colliding gear: {msg}"
);
assert!(
msg.contains("a-cookbook.json"),
"error must mention the first source file (sorted): {msg}"
);
assert!(
msg.contains("z-cookbook.json"),
"error must mention the second source file: {msg}"
);
}
#[test]
fn skips_malformed_files_and_loads_the_rest() {
let tmp = tempfile::tempdir().unwrap();
let dir = gear_dir(tmp.path());
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("broken.json"), "{not valid json").unwrap();
write_gear_file(
tmp.path(),
"cookbook-github.json",
&one_gear_json("pr", "PR"),
);
let result = LoadedGear::from_env_dir(tmp.path())
.map(LoadedGear::into_map)
.unwrap();
assert_eq!(result.len(), 1, "one good file must still be loaded");
assert!(result.contains_key("pr"));
}
#[test]
fn ignores_non_json_files() {
let tmp = tempfile::tempdir().unwrap();
let dir = gear_dir(tmp.path());
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("README.md"), "this is not gear").unwrap();
write_gear_file(
tmp.path(),
"cookbook-github.json",
&one_gear_json("pr", "PR"),
);
let result = LoadedGear::from_env_dir(tmp.path())
.map(LoadedGear::into_map)
.unwrap();
assert_eq!(result.len(), 1);
}
}
}