use std::collections::HashSet;
use std::fmt;
use std::str::FromStr;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::relationships::RelationKind;
pub const CREATE_FIELDS: &[&str] = &[
"title",
"body",
"status",
"priority",
"assignee",
"labels",
"files",
"estimate",
"cycles",
"milestones",
"reviewer",
"start_date",
"due_date",
"parent",
"related_issues",
"type",
];
pub const OPTIONS_SOURCES: &[&str] = &[
"statuses",
"priorities",
"labels",
"assignees",
"cycles",
"milestones",
];
fn is_repository_scope(scope: &TemplateScope) -> bool {
scope.is_repository()
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(value: &bool) -> bool {
!*value
}
fn field_error(id: &str, field: &str, message: impl fmt::Display) -> TixError {
TixError::Config(format!("`issue_templates.{id}.{field}` {message}"))
}
fn validate_id(id: &str) -> Result<()> {
if id.trim().is_empty() {
return Err(TixError::Config("a template id must not be blank".into()));
}
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(TixError::Config(format!(
"template id `{id}` must be a stable handle of ASCII letters, digits, `-` or `_`"
)));
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum TemplateScopeKind {
#[default]
Repository,
Board,
Team,
}
impl fmt::Display for TemplateScopeKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
TemplateScopeKind::Repository => "repository",
TemplateScopeKind::Board => "board",
TemplateScopeKind::Team => "team",
})
}
}
impl FromStr for TemplateScopeKind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"repository" => Ok(TemplateScopeKind::Repository),
"board" => Ok(TemplateScopeKind::Board),
"team" => Ok(TemplateScopeKind::Team),
other => Err(format!(
"unknown scope kind `{other}`; expected `repository`, `board:<name>` or `team:<label>`"
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TemplateScope {
kind: TemplateScopeKind,
qualifier: Option<String>,
}
impl TemplateScope {
#[must_use]
pub fn repository() -> Self {
Self {
kind: TemplateScopeKind::Repository,
qualifier: None,
}
}
#[must_use]
pub fn board(name: impl Into<String>) -> Self {
Self {
kind: TemplateScopeKind::Board,
qualifier: Some(name.into()),
}
}
#[must_use]
pub fn team(label: impl Into<String>) -> Self {
Self {
kind: TemplateScopeKind::Team,
qualifier: Some(label.into()),
}
}
#[must_use]
pub fn kind(&self) -> TemplateScopeKind {
self.kind
}
#[must_use]
pub fn qualifier(&self) -> Option<&str> {
self.qualifier.as_deref()
}
#[must_use]
pub fn is_repository(&self) -> bool {
self.kind == TemplateScopeKind::Repository
}
fn validate(&self, id: &str, config: &Config) -> Result<()> {
let qualifier = match self.kind {
TemplateScopeKind::Repository => return Ok(()),
TemplateScopeKind::Board => self.qualifier.as_deref().unwrap_or_default(),
TemplateScopeKind::Team => self.qualifier.as_deref().unwrap_or_default(),
};
if qualifier.trim().is_empty() {
let expected = match self.kind {
TemplateScopeKind::Team => "team:<label>",
_ => "board:<name>",
};
return Err(field_error(
id,
"scope",
format!("must name its {} scope as `{expected}`", self.kind),
));
}
match self.kind {
TemplateScopeKind::Board if !config.kanban.configurations.contains_key(qualifier) => {
Err(field_error(
id,
"scope",
format!("references unknown board `{qualifier}`"),
))
}
TemplateScopeKind::Team if !config.teams.contains_key(qualifier) => Err(field_error(
id,
"scope",
format!("references unknown team `{qualifier}`"),
)),
_ => Ok(()),
}
}
}
impl fmt::Display for TemplateScope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match (self.kind, self.qualifier.as_deref()) {
(TemplateScopeKind::Board, Some(name)) => write!(f, "board:{name}"),
(TemplateScopeKind::Team, Some(label)) => write!(f, "team:{label}"),
(kind, _) => write!(f, "{kind}"),
}
}
}
impl FromStr for TemplateScope {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (kind, qualifier) = match s.split_once(':') {
Some((kind, qualifier)) => (kind, Some(qualifier)),
None => (s, None),
};
match kind {
"repository" => Ok(TemplateScope::repository()),
"board" => Ok(Self {
kind: TemplateScopeKind::Board,
qualifier: qualifier.map(str::trim).map(str::to_string),
}),
"team" => Ok(Self {
kind: TemplateScopeKind::Team,
qualifier: qualifier.map(str::trim).map(str::to_string),
}),
other => Err(format!(
"unknown scope `{s}` (kind `{other}`); expected `repository`, `board:<name>` or `team:<label>`"
)),
}
}
}
impl Serialize for TemplateScope {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for TemplateScope {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer)?
.parse()
.map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TemplateDefaults {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub files: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub estimate: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub cycles: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub milestones: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reviewer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub due_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub related_issues: Vec<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub type_: Option<String>,
}
impl TemplateDefaults {
#[must_use]
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PromptKind {
#[default]
Text,
Select,
MultiSelect,
Date,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TemplatePrompt {
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub kind: PromptKind,
#[serde(default, skip_serializing_if = "is_false")]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub options_ref: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReferenceKind {
Label,
Assignee,
File,
Cycle,
Milestone,
Relationship,
}
impl ReferenceKind {
fn validate_value(self, id: &str, index: usize, value: &str) -> Result<()> {
let path = format!("references[{index}].value");
if value.trim().is_empty() {
return Err(field_error(id, &path, "must not be blank"));
}
if self == ReferenceKind::Relationship && RelationKind::from_str(value).is_err() {
let kinds: Vec<&str> = RelationKind::ALL.iter().map(|k| k.as_str()).collect();
return Err(field_error(
id,
&path,
format!(
"`{value}` is not a relation kind; expected one of {}",
kinds.join(", ")
),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TemplateReference {
pub kind: ReferenceKind,
pub value: String,
#[serde(default, skip_serializing_if = "is_false")]
pub required: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TemplateMetadata(toml::Table);
impl TemplateMetadata {
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&toml::Value> {
self.0.get(key)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &toml::Value)> {
self.0.iter().map(|(k, v)| (k.as_str(), v))
}
#[must_use]
pub fn table(&self) -> &toml::Table {
&self.0
}
}
impl PartialEq for TemplateMetadata {
fn eq(&self, other: &Self) -> bool {
toml::Value::Table(self.0.clone()).to_string()
== toml::Value::Table(other.0.clone()).to_string()
}
}
impl Eq for TemplateMetadata {}
impl From<toml::Table> for TemplateMetadata {
fn from(table: toml::Table) -> Self {
Self(table)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Template {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "is_repository_scope")]
pub scope: TemplateScope,
#[serde(skip_serializing_if = "TemplateDefaults::is_empty")]
pub defaults: TemplateDefaults,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub prompts: Vec<TemplatePrompt>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub references: Vec<TemplateReference>,
#[serde(flatten)]
pub extensions: TemplateMetadata,
}
impl Template {
#[must_use]
pub fn display_name<'a>(&'a self, id: &'a str) -> &'a str {
self.name.as_deref().unwrap_or(id)
}
pub fn validate(&self, id: &str, config: &Config) -> Result<()> {
validate_id(id)?;
if let Some(name) = &self.name
&& name.trim().is_empty()
{
return Err(field_error(id, "name", "must not be blank when present"));
}
self.scope.validate(id, config)?;
validate_defaults(id, &self.defaults)?;
validate_prompts(id, &self.prompts)?;
for (index, reference) in self.references.iter().enumerate() {
reference.kind.validate_value(id, index, &reference.value)?;
}
Ok(())
}
}
fn validate_defaults(id: &str, defaults: &TemplateDefaults) -> Result<()> {
for (field, value) in [
("title", &defaults.title),
("body", &defaults.body),
("status", &defaults.status),
("priority", &defaults.priority),
("assignee", &defaults.assignee),
("estimate", &defaults.estimate),
("reviewer", &defaults.reviewer),
("parent", &defaults.parent),
("type", &defaults.type_),
] {
if let Some(value) = value
&& value.trim().is_empty()
{
return Err(field_error(
id,
&format!("defaults.{field}"),
"must not be blank when present",
));
}
}
for (field, values) in [
("labels", &defaults.labels),
("files", &defaults.files),
("cycles", &defaults.cycles),
("milestones", &defaults.milestones),
("related_issues", &defaults.related_issues),
] {
for value in values {
if value.trim().is_empty() {
return Err(field_error(
id,
&format!("defaults.{field}"),
"must not contain a blank value",
));
}
}
}
for (field, value) in [
("start_date", &defaults.start_date),
("due_date", &defaults.due_date),
] {
if let Some(value) = value
&& !crate::model::validate_date(value)
{
return Err(field_error(
id,
&format!("defaults.{field}"),
format!("`{value}` must be a `YYYY-MM-DD` calendar date"),
));
}
}
Ok(())
}
fn validate_prompts(id: &str, prompts: &[TemplatePrompt]) -> Result<()> {
let mut seen: HashSet<&str> = HashSet::new();
for (index, prompt) in prompts.iter().enumerate() {
let key_path = format!("prompts[{index}].key");
if prompt.key.trim().is_empty() {
return Err(field_error(id, &key_path, "must not be blank"));
}
if !CREATE_FIELDS.contains(&prompt.key.as_str()) {
return Err(field_error(
id,
&key_path,
format!(
"`{}` is not a create field; expected one of {}",
prompt.key,
CREATE_FIELDS.join(", ")
),
));
}
if !seen.insert(prompt.key.as_str()) {
return Err(field_error(
id,
&key_path,
format!("duplicates the prompt for `{}`", prompt.key),
));
}
if let Some(label) = &prompt.label
&& label.trim().is_empty()
{
return Err(field_error(
id,
&format!("prompts[{index}].label"),
"must not be blank when present",
));
}
if let Some(options) = &prompt.options_ref {
if options.trim().is_empty() {
return Err(field_error(
id,
&format!("prompts[{index}].options_ref"),
"must not be blank when present",
));
}
if !OPTIONS_SOURCES.contains(&options.as_str()) {
return Err(field_error(
id,
&format!("prompts[{index}].options_ref"),
format!(
"`{options}` is not a known options source; expected one of {}",
OPTIONS_SOURCES.join(", ")
),
));
}
}
if let Some(default) = &prompt.default {
if default.trim().is_empty() {
return Err(field_error(
id,
&format!("prompts[{index}].default"),
"must not be blank when present",
));
}
if prompt.kind == PromptKind::Date && !crate::model::validate_date(default) {
return Err(field_error(
id,
&format!("prompts[{index}].default"),
format!("`{default}` must be a `YYYY-MM-DD` calendar date"),
));
}
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, transparent)]
pub struct TemplateRegistry {
pub templates: IndexMap<String, Template>,
}
impl TemplateRegistry {
#[must_use]
pub fn is_empty(&self) -> bool {
self.templates.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.templates.len()
}
#[must_use]
pub fn contains(&self, id: &str) -> bool {
self.templates.contains_key(id)
}
#[must_use]
pub fn get(&self, id: &str) -> Option<&Template> {
self.templates.get(id)
}
pub fn get_mut(&mut self, id: &str) -> Option<&mut Template> {
self.templates.get_mut(id)
}
pub fn ids(&self) -> impl Iterator<Item = &str> {
self.templates.keys().map(String::as_str)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &Template)> {
self.templates.iter().map(|(id, t)| (id.as_str(), t))
}
pub fn insert(&mut self, id: impl Into<String>, template: Template) -> Result<()> {
let id = id.into();
validate_id(&id)?;
if self.templates.contains_key(&id) {
return Err(TixError::Config(format!(
"a template with id `{id}` already exists"
)));
}
self.templates.insert(id, template);
Ok(())
}
pub fn remove(&mut self, id: &str) -> Option<Template> {
self.templates.shift_remove(id)
}
pub fn validate(&self, config: &Config) -> Result<()> {
for (id, template) in &self.templates {
template.validate(id, config)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
fn prompt(key: &str, kind: PromptKind) -> TemplatePrompt {
TemplatePrompt {
key: key.to_string(),
label: None,
kind,
required: false,
default: None,
options_ref: None,
}
}
#[test]
fn a_rename_keeps_the_stable_id() {
let mut registry = TemplateRegistry::default();
registry
.insert(
"bug",
Template {
name: Some("Bug report".to_string()),
defaults: TemplateDefaults {
labels: vec!["bug".to_string()],
..TemplateDefaults::default()
},
prompts: vec![prompt("priority", PromptKind::Select)],
..Template::default()
},
)
.unwrap();
registry.get_mut("bug").unwrap().name = Some("Incident report".to_string());
let bug = registry
.get("bug")
.expect("the stable ID survives a rename");
assert_eq!(bug.display_name("bug"), "Incident report");
assert_eq!(bug.defaults.labels, ["bug".to_string()]);
assert_eq!(bug.prompts[0].key, "priority");
assert_eq!(registry.ids().collect::<Vec<_>>(), ["bug"]);
}
#[test]
fn every_scope_kind_parses_and_round_trips() {
for (text, kind) in [
("repository", TemplateScopeKind::Repository),
("board:default", TemplateScopeKind::Board),
("team:core", TemplateScopeKind::Team),
] {
let scope = TemplateScope::from_str(text).unwrap();
assert_eq!(scope.kind(), kind, "{text}");
assert_eq!(scope.to_string(), text, "{text}");
}
assert!(!TemplateScope::from_str("board").unwrap().is_repository());
assert!(TemplateScope::from_str("sparkles").is_err());
}
#[test]
fn a_known_board_scope_validates() {
let config = Config::default();
assert!(config.kanban.configurations.contains_key("default"));
let template = Template {
scope: TemplateScope::board("default"),
..Template::default()
};
template.validate("bug", &config).unwrap();
}
#[test]
fn an_unknown_board_scope_is_rejected() {
let err = Template {
scope: TemplateScope::board("no-such-board"),
..Template::default()
}
.validate("bug", &Config::default())
.unwrap_err()
.to_string();
assert!(err.contains("issue_templates.bug.scope"), "{err}");
assert!(err.contains("unknown board `no-such-board`"), "{err}");
}
#[test]
fn an_unknown_team_scope_is_rejected() {
let config = Config::default();
let template = Template {
scope: TemplateScope::team("core"),
..Template::default()
};
let err = template.validate("bug", &config).unwrap_err().to_string();
assert!(err.contains("unknown team `core`"), "{err}");
let mut with_team = config;
with_team.teams.insert(
"core".to_string(),
crate::config::Team {
members: vec!["ada".to_string()],
},
);
template.validate("bug", &with_team).unwrap();
}
#[test]
fn a_scope_without_a_qualifier_is_rejected() {
let config = Config::default();
for text in ["board", "team", "board: ", "team:"] {
let scope = TemplateScope::from_str(text).unwrap();
let template = Template {
scope,
..Template::default()
};
let err = template.validate("bug", &config).unwrap_err().to_string();
assert!(err.contains("must name its"), "{text}: {err}");
}
}
#[test]
fn a_duplicate_or_malformed_id_is_refused_without_mutating() {
let mut registry = TemplateRegistry::default();
registry.insert("bug", Template::default()).unwrap();
let err = registry
.insert("bug", Template::default())
.unwrap_err()
.to_string();
assert!(err.contains("already exists"), "{err}");
for bad in ["", " ", "has space", "has.dot", "quoted\""] {
assert!(
registry.insert(bad, Template::default()).is_err(),
"{bad:?}"
);
}
assert_eq!(registry.len(), 1);
assert!(registry.contains("bug"));
}
#[test]
fn remove_is_definition_only() {
let mut registry = TemplateRegistry::default();
registry.insert("bug", Template::default()).unwrap();
assert!(registry.remove("bug").is_some());
assert!(registry.is_empty());
assert!(registry.remove("bug").is_none());
}
#[test]
fn prompts_and_references_round_trip_in_order() {
let mut registry = TemplateRegistry::default();
registry
.insert(
"bug",
Template {
name: Some("Bug report".to_string()),
prompts: vec![
prompt("priority", PromptKind::Select),
prompt("labels", PromptKind::MultiSelect),
prompt("title", PromptKind::Text),
],
references: vec![
TemplateReference {
kind: ReferenceKind::Label,
value: "bug".to_string(),
required: true,
},
TemplateReference {
kind: ReferenceKind::Cycle,
value: "Cycle 1".to_string(),
required: false,
},
],
..Template::default()
},
)
.unwrap();
let text = toml::to_string_pretty(®istry).unwrap();
let back: TemplateRegistry = toml::from_str(&text).unwrap();
assert_eq!(back, registry);
let bug = back.get("bug").unwrap();
assert_eq!(
bug.prompts
.iter()
.map(|p| p.key.as_str())
.collect::<Vec<_>>(),
["priority", "labels", "title"],
"prompt order is data"
);
assert_eq!(
bug.references.iter().map(|r| r.kind).collect::<Vec<_>>(),
[ReferenceKind::Label, ReferenceKind::Cycle]
);
assert!(bug.references[0].required);
assert!(!bug.references[1].required);
}
#[test]
fn an_unknown_prompt_key_is_rejected() {
let config = Config::default();
let ok = Template {
prompts: vec![prompt("priority", PromptKind::Select)],
..Template::default()
};
ok.validate("bug", &config).unwrap();
let err = Template {
prompts: vec![prompt("sparkles", PromptKind::Text)],
..Template::default()
}
.validate("bug", &config)
.unwrap_err()
.to_string();
assert!(err.contains("issue_templates.bug.prompts[0].key"), "{err}");
assert!(err.contains("is not a create field"), "{err}");
}
#[test]
fn a_duplicate_prompt_key_is_rejected() {
let err = Template {
prompts: vec![
prompt("priority", PromptKind::Select),
prompt("priority", PromptKind::Text),
],
..Template::default()
}
.validate("bug", &Config::default())
.unwrap_err()
.to_string();
assert!(err.contains("duplicates the prompt"), "{err}");
}
#[test]
fn an_unknown_options_source_is_rejected() {
let template = Template {
prompts: vec![TemplatePrompt {
options_ref: Some("sparkles".to_string()),
..prompt("status", PromptKind::Select)
}],
..Template::default()
};
let err = template
.validate("bug", &Config::default())
.unwrap_err()
.to_string();
assert!(err.contains("is not a known options source"), "{err}");
}
#[test]
fn dates_in_defaults_and_prompts_are_validated() {
let bad_default = Template {
defaults: TemplateDefaults {
due_date: Some("not-a-date".to_string()),
..TemplateDefaults::default()
},
..Template::default()
};
assert!(
bad_default
.validate("bug", &Config::default())
.unwrap_err()
.to_string()
.contains("defaults.due_date")
);
let bad_prompt = Template {
prompts: vec![TemplatePrompt {
kind: PromptKind::Date,
default: Some("2026-13-40".to_string()),
..prompt("due_date", PromptKind::Date)
}],
..Template::default()
};
assert!(
bad_prompt
.validate("bug", &Config::default())
.unwrap_err()
.to_string()
.contains("YYYY-MM-DD")
);
}
#[test]
fn a_relationship_reference_must_name_a_relation_kind() {
let config = Config::default();
let ok = Template {
references: vec![TemplateReference {
kind: ReferenceKind::Relationship,
value: "blocks".to_string(),
required: false,
}],
..Template::default()
};
ok.validate("bug", &config).unwrap();
Template {
references: vec![TemplateReference {
kind: ReferenceKind::Label,
value: "whatever-this-project-uses".to_string(),
required: false,
}],
..Template::default()
}
.validate("bug", &config)
.unwrap();
let err = Template {
references: vec![TemplateReference {
kind: ReferenceKind::Relationship,
value: "sparkles".to_string(),
required: false,
}],
..Template::default()
}
.validate("bug", &config)
.unwrap_err()
.to_string();
assert!(err.contains("is not a relation kind"), "{err}");
}
#[test]
fn unknown_metadata_survives_a_round_trip() {
let text =
"[bug]\nname = \"Bug report\"\ncustom_field = \"keep me\"\n\n[bug.future]\nlevel = 3\n";
let registry: TemplateRegistry = toml::from_str(text).unwrap();
let bug = registry.get("bug").unwrap();
assert_eq!(
bug.extensions
.get("custom_field")
.and_then(toml::Value::as_str),
Some("keep me")
);
assert!(bug.extensions.get("future").is_some());
let serialized = toml::to_string_pretty(®istry).unwrap();
let back: TemplateRegistry = toml::from_str(&serialized).unwrap();
assert_eq!(back, registry, "unknown metadata survives a round trip");
assert_eq!(toml::to_string_pretty(&back).unwrap(), serialized);
}
#[test]
fn an_unknown_default_field_is_diagnosed() {
let text = "[bug]\nname = \"Bug\"\n\n[bug.defaults]\nlabes = [\"typo\"]\n";
let err = toml::from_str::<TemplateRegistry>(text)
.unwrap_err()
.to_string();
assert!(err.contains("unknown field"), "{err}");
}
}