use alloc::collections::BTreeMap;
use schemars::{JsonSchema, schema_for};
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[schemars(title = "Lintel Catalog")]
pub struct CatalogConfig {
#[allow(dead_code)]
pub catalog: CatalogMeta,
#[serde(default)]
pub target: BTreeMap<String, TargetConfig>,
#[serde(default)]
pub groups: BTreeMap<String, GroupConfig>,
#[serde(default)]
pub sources: BTreeMap<String, SourceConfig>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[schemars(title = "Catalog Metadata")]
pub struct CatalogMeta {
#[schemars(example = &"Lintel Schema Catalog")]
#[serde(default)]
pub title: Option<String>,
#[schemars(example = &"https://raw.githubusercontent.com/lintel-rs/catalog/master")]
#[serde(default)]
pub source_base_url: Option<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[schemars(title = "GitHub Pages Options")]
pub struct GitHubPagesConfig {
#[schemars(example = &"catalog.example.com")]
#[serde(default)]
pub cname: Option<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[schemars(title = "Site Config")]
pub struct SiteConfig {
#[serde(default)]
pub description: Option<String>,
#[schemars(example = &"G-XXXXXXXXXX")]
#[serde(default)]
pub ga_tracking_id: Option<String>,
#[schemars(example = &"https://catalog.example.com/og-image.png")]
#[serde(default)]
pub og_image: Option<String>,
#[serde(default)]
pub github: Option<GitHubPagesConfig>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[schemars(title = "Build Target")]
pub struct TargetConfig {
#[schemars(example = &"../catalog-generated")]
pub dir: String,
#[schemars(example = &"https://raw.githubusercontent.com/org/catalog/master/")]
#[serde(alias = "base_url")]
pub base_url: String,
#[serde(default)]
pub site: Option<SiteConfig>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[schemars(title = "Schema Group")]
pub struct GroupConfig {
#[schemars(example = &"GitHub", example = &"Claude Code")]
pub name: String,
pub description: String,
#[serde(default)]
pub schemas: BTreeMap<String, SchemaDefinition>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[schemars(title = "Schema Definition")]
pub struct SchemaDefinition {
pub url: Option<String>,
#[schemars(example = &"GitHub Workflow", example = &"devenv.yaml")]
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[schemars(title = "File Match", example = &["**/.github/workflows/*.yml"], example = &["devenv.yaml"])]
#[serde(default)]
pub file_match: Vec<String>,
#[serde(default)]
pub versions: BTreeMap<String, String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[schemars(title = "External Catalog Source")]
pub struct SourceConfig {
#[schemars(example = &"https://www.schemastore.org/api/json/catalog.json")]
pub url: String,
#[schemars(example = &["biome.jsonc"])]
#[serde(default)]
pub exclude_matches: Vec<String>,
#[serde(default)]
pub organize: BTreeMap<String, OrganizeEntry>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[schemars(title = "Organize Entry")]
pub struct OrganizeEntry {
#[schemars(example = &["**.github**"], example = &["*docker*"])]
#[serde(rename = "match")]
pub match_patterns: Vec<String>,
}
pub fn schema() -> Value {
serde_json::to_value(schema_for!(CatalogConfig)).expect("schema serialization cannot fail")
}
pub fn load_config(toml_str: &str) -> Result<CatalogConfig, toml::de::Error> {
toml::from_str(toml_str)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_config() {
let toml = r"
[catalog]
";
let config = load_config(toml).expect("parse");
assert!(config.target.is_empty());
assert!(config.groups.is_empty());
assert!(config.sources.is_empty());
}
#[test]
fn parse_full_config_kebab_case() {
let toml = r#"
[catalog]
[target.local]
dir = "../catalog-generated"
base-url = "https://raw.githubusercontent.com/lintel-rs/catalog/master/"
[target.local.site]
description = "A test catalog"
ga-tracking-id = "G-TEST123"
github.cname = "catalog.example.com"
[groups.claude-code]
name = "Claude Code"
description = "Schemas for Claude Code configuration files"
[groups.claude-code.schemas]
agent = { name = "Claude Code Agent", description = "Agent definition", file-match = ["**/.claude/agents/*.md"] }
skill = { name = "Claude Code Skill", description = "Skill definition", file-match = ["**/skills/*.md"] }
[groups.devenv]
name = "devenv"
description = "Nix-based development environment configuration"
[groups.devenv.schemas]
devenv = { url = "https://devenv.sh/devenv.schema.json", name = "devenv.yaml", description = "devenv config", file-match = ["devenv.yaml"] }
[groups.github]
name = "GitHub"
description = "GitHub configuration files"
[sources.schemastore]
url = "https://www.schemastore.org/api/json/catalog.json"
[sources.schemastore.organize.github]
match = ["**.github**"]
"#;
let config = load_config(toml).expect("parse");
assert_eq!(config.target.len(), 1);
let local = &config.target["local"];
assert_eq!(local.dir, "../catalog-generated");
assert_eq!(
local.base_url,
"https://raw.githubusercontent.com/lintel-rs/catalog/master/"
);
let site = local.site.as_ref().expect("site should be present");
assert_eq!(site.description.as_deref(), Some("A test catalog"));
assert_eq!(site.ga_tracking_id.as_deref(), Some("G-TEST123"));
let gh = site.github.as_ref().expect("github should be present");
assert_eq!(gh.cname.as_deref(), Some("catalog.example.com"));
assert_eq!(config.groups.len(), 3);
let claude_code = &config.groups["claude-code"];
assert_eq!(claude_code.name, "Claude Code");
assert_eq!(claude_code.schemas.len(), 2);
assert_eq!(
claude_code.schemas["agent"].name.as_deref(),
Some("Claude Code Agent")
);
assert!(claude_code.schemas["agent"].url.is_none());
assert_eq!(
claude_code.schemas["agent"].file_match,
vec!["**/.claude/agents/*.md"]
);
let devenv = &config.groups["devenv"];
assert_eq!(
devenv.schemas["devenv"].url.as_deref(),
Some("https://devenv.sh/devenv.schema.json")
);
assert_eq!(config.sources.len(), 1);
let ss = &config.sources["schemastore"];
assert_eq!(ss.url, "https://www.schemastore.org/api/json/catalog.json");
let github_org = &ss.organize["github"];
assert_eq!(github_org.match_patterns, vec!["**.github**"]);
}
#[test]
fn base_url_snake_case_alias_accepted() {
let toml = r#"
[catalog]
[target.local]
dir = "out"
base_url = "https://example.com/"
"#;
let config = load_config(toml).expect("snake_case base_url should be accepted");
assert_eq!(config.target["local"].base_url, "https://example.com/");
}
#[test]
fn parse_source_with_exclude_matches() {
let toml = r#"
[catalog]
[sources.schemastore]
url = "https://www.schemastore.org/api/json/catalog.json"
exclude-matches = ["biome.jsonc", "prettier.json"]
[sources.schemastore.organize.github]
match = ["**.github**"]
"#;
let config = load_config(toml).expect("parse");
let ss = &config.sources["schemastore"];
assert_eq!(ss.exclude_matches, vec!["biome.jsonc", "prettier.json"]);
}
#[test]
fn parse_source_exclude_matches_defaults_empty() {
let toml = r#"
[catalog]
[sources.schemastore]
url = "https://www.schemastore.org/api/json/catalog.json"
"#;
let config = load_config(toml).expect("parse");
let ss = &config.sources["schemastore"];
assert!(ss.exclude_matches.is_empty());
}
#[test]
fn parse_source_base_url() {
let toml = r#"
[catalog]
source-base-url = "https://raw.githubusercontent.com/lintel-rs/catalog/master"
"#;
let config = load_config(toml).expect("parse");
assert_eq!(
config.catalog.source_base_url.as_deref(),
Some("https://raw.githubusercontent.com/lintel-rs/catalog/master")
);
}
#[test]
fn source_base_url_defaults_to_none() {
let toml = r"
[catalog]
";
let config = load_config(toml).expect("parse");
assert!(config.catalog.source_base_url.is_none());
}
#[test]
fn unknown_fields_rejected() {
let toml = r"
[catalog]
unknown_field = 'bad'
";
assert!(load_config(toml).is_err());
}
#[test]
fn target_with_github_pages_site_config() {
let toml = r#"
[catalog]
[target.pages]
dir = "docs"
base-url = "https://example.github.io/catalog/"
[target.pages.site]
github.cname = "catalog.example.com"
"#;
let config = load_config(toml).expect("parse");
let pages = &config.target["pages"];
let gh = pages
.site
.as_ref()
.and_then(|s| s.github.as_ref())
.expect("github config should be present");
assert_eq!(gh.cname.as_deref(), Some("catalog.example.com"));
}
#[test]
fn target_without_site_config() {
let toml = r#"
[catalog]
[target.local]
dir = "out"
base-url = "https://example.com/"
"#;
let config = load_config(toml).expect("parse");
assert!(config.target["local"].site.is_none());
}
}