use std::fmt;
use crate::config::Config;
use crate::model::{Priority, Status};
use super::registry::{
CREATE_FIELDS, Template, TemplateDefaults, TemplateScope, TemplateScopeKind,
};
use super::validation::{self, DiagnosticKind, TemplateDiagnostic};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateSelection {
pub id: String,
pub scope: TemplateScope,
}
impl TemplateSelection {
pub fn new(id: impl Into<String>, scope: TemplateScope) -> Self {
Self {
id: id.into(),
scope,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TemplateInput {
pub title: Option<String>,
pub body: Option<String>,
pub status: Option<String>,
pub priority: Option<String>,
pub assignee: Option<String>,
pub labels: Option<String>,
pub files: Option<String>,
pub estimate: Option<String>,
pub cycles: Option<String>,
pub milestones: Option<String>,
pub reviewer: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
pub parent: Option<String>,
pub related_issues: Option<String>,
pub type_: Option<String>,
}
impl TemplateInput {
#[must_use]
pub fn get(&self, field: &str) -> Option<&str> {
match field {
"title" => self.title.as_deref(),
"body" => self.body.as_deref(),
"status" => self.status.as_deref(),
"priority" => self.priority.as_deref(),
"assignee" => self.assignee.as_deref(),
"labels" => self.labels.as_deref(),
"files" => self.files.as_deref(),
"estimate" => self.estimate.as_deref(),
"cycles" => self.cycles.as_deref(),
"milestones" => self.milestones.as_deref(),
"reviewer" => self.reviewer.as_deref(),
"start_date" => self.start_date.as_deref(),
"due_date" => self.due_date.as_deref(),
"parent" => self.parent.as_deref(),
"related_issues" => self.related_issues.as_deref(),
"type" => self.type_.as_deref(),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateRequest {
pub title: String,
pub body: Option<String>,
pub status: Option<Status>,
pub priority: Option<Priority>,
pub assignee: Option<String>,
pub labels: Option<String>,
pub files: Option<String>,
pub estimate: Option<String>,
pub cycles: Option<String>,
pub milestones: Option<String>,
pub reviewer: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
pub parent: Option<String>,
pub related_issues: Option<String>,
pub type_: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateResolution {
template_id: String,
scope: TemplateScope,
provided_fields: Vec<String>,
diagnostics: Vec<TemplateDiagnostic>,
request: Option<CreateRequest>,
}
impl TemplateResolution {
#[must_use]
pub fn template_id(&self) -> &str {
&self.template_id
}
#[must_use]
pub fn scope(&self) -> &TemplateScope {
&self.scope
}
#[must_use]
pub fn provided_fields(&self) -> &[String] {
&self.provided_fields
}
#[must_use]
pub fn diagnostics(&self) -> &[TemplateDiagnostic] {
&self.diagnostics
}
#[must_use]
pub fn request(&self) -> Option<&CreateRequest> {
self.request.as_ref()
}
#[must_use]
pub fn is_ok(&self) -> bool {
self.request.is_some()
}
#[must_use]
pub fn into_request(self) -> Option<CreateRequest> {
self.request
}
fn rejected(
template_id: &str,
scope: &TemplateScope,
diagnostics: Vec<TemplateDiagnostic>,
) -> Self {
Self {
template_id: template_id.to_string(),
scope: scope.clone(),
provided_fields: Vec::new(),
diagnostics,
request: None,
}
}
}
impl fmt::Display for TemplateResolution {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_ok() {
write!(
f,
"template `{}` at scope `{}` resolved",
self.template_id, self.scope
)
} else {
write!(
f,
"template `{}` at scope `{}` did not resolve ({} diagnostic(s))",
self.template_id,
self.scope,
self.diagnostics.len()
)
}
}
}
#[must_use]
pub fn resolve(
config: &Config,
selection: &TemplateSelection,
input: &TemplateInput,
) -> TemplateResolution {
let id = selection.id.trim().to_string();
if id.is_empty() {
return TemplateResolution::rejected(
&id,
&selection.scope,
vec![TemplateDiagnostic::new(
DiagnosticKind::UnknownTemplate,
"template_id",
"a template id is required (a display name is not identity)",
)],
);
}
let Some(template) = config.issue_templates.get(&id) else {
return TemplateResolution::rejected(
&id,
&selection.scope,
vec![TemplateDiagnostic::new(
DiagnosticKind::UnknownTemplate,
"template_id",
unknown_template_message(config, &id),
)],
);
};
if let Err(error) = template.validate(&id, config) {
return TemplateResolution::rejected(
&id,
&selection.scope,
vec![TemplateDiagnostic::new(
DiagnosticKind::InvalidValue,
"template",
error.to_string(),
)],
);
}
let scope_diagnostics = scope_diagnostics(config, &id, selection, template);
if !scope_diagnostics.is_empty() {
return TemplateResolution::rejected(&id, &selection.scope, scope_diagnostics);
}
let merged = merge(template, input);
let mut diagnostics = validation::missing_required(template, &merged);
diagnostics.extend(validation::invalid_values(&merged));
diagnostics.extend(validation::validate_references(config, template, &merged));
let provided_fields = merged
.iter()
.map(|(field, _)| field.to_string())
.collect::<Vec<_>>();
let request = diagnostics.is_empty().then(|| build_request(&merged));
TemplateResolution {
template_id: id,
scope: selection.scope.clone(),
provided_fields,
diagnostics,
request,
}
}
fn unknown_template_message(config: &Config, id: &str) -> String {
if let Some((stable, _)) = config
.issue_templates
.iter()
.find(|(_, template)| template.display_name("") == id)
{
return format!("`{id}` is a display name, not a stable id; use `{stable}`");
}
let known = config.issue_templates.ids().collect::<Vec<_>>();
let list = if known.is_empty() {
"none configured".to_string()
} else {
known.join(", ")
};
format!("unknown template id `{id}` — known ids: {list}")
}
fn scope_diagnostics(
config: &Config,
id: &str,
selection: &TemplateSelection,
template: &Template,
) -> Vec<TemplateDiagnostic> {
let requested = &selection.scope;
let mut out = Vec::new();
let qualifier = requested.qualifier().unwrap_or_default().trim();
match requested.kind() {
TemplateScopeKind::Repository => {}
TemplateScopeKind::Board if qualifier.is_empty() => out.push(TemplateDiagnostic::new(
DiagnosticKind::InvalidScope,
"scope",
"a board scope must name its board as `board:<name>`",
)),
TemplateScopeKind::Board => {
if !config.kanban.configurations.contains_key(qualifier) {
out.push(TemplateDiagnostic::new(
DiagnosticKind::InvalidScope,
"scope",
format!("references unknown board `{qualifier}`"),
));
}
}
TemplateScopeKind::Team if qualifier.is_empty() => out.push(TemplateDiagnostic::new(
DiagnosticKind::InvalidScope,
"scope",
"a team scope must name its team as `team:<label>`",
)),
TemplateScopeKind::Team => {
if !config.teams.contains_key(qualifier) {
out.push(TemplateDiagnostic::new(
DiagnosticKind::InvalidScope,
"scope",
format!("references unknown team `{qualifier}`"),
));
}
}
}
if out.is_empty() && requested != &template.scope {
out.push(TemplateDiagnostic::new(
DiagnosticKind::ScopeMismatch,
"scope",
format!(
"template `{id}` is scoped to `{}`, not `{}`",
template.scope, requested
),
));
}
out
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct MergedFields {
entries: Vec<(&'static str, String)>,
}
impl MergedFields {
fn set(&mut self, field: &'static str, value: String) {
self.entries.push((field, value));
}
pub(crate) fn get(&self, field: &str) -> Option<&str> {
self.entries
.iter()
.find(|(key, _)| *key == field)
.map(|(_, value)| value.as_str())
}
pub(crate) fn is_blank(&self, field: &str) -> bool {
self.get(field).is_none_or(|value| value.trim().is_empty())
}
pub(crate) fn iter(&self) -> impl Iterator<Item = (&'static str, &str)> {
self.entries
.iter()
.map(|(field, value)| (*field, value.as_str()))
}
}
fn merge(template: &Template, input: &TemplateInput) -> MergedFields {
let mut merged = MergedFields::default();
for field in CREATE_FIELDS {
let value = input
.get(field)
.map(str::to_string)
.or_else(|| prompt_default(template, field))
.or_else(|| defaults_value(&template.defaults, field));
if let Some(value) = value {
merged.set(field, value);
}
}
merged
}
fn prompt_default(template: &Template, field: &str) -> Option<String> {
template
.prompts
.iter()
.find(|prompt| prompt.key == field)
.and_then(|prompt| non_blank(&prompt.default))
}
fn defaults_value(defaults: &TemplateDefaults, field: &str) -> Option<String> {
let scalar = match field {
"title" => &defaults.title,
"body" => &defaults.body,
"status" => &defaults.status,
"priority" => &defaults.priority,
"assignee" => &defaults.assignee,
"estimate" => &defaults.estimate,
"reviewer" => &defaults.reviewer,
"start_date" => &defaults.start_date,
"due_date" => &defaults.due_date,
"parent" => &defaults.parent,
"type" => &defaults.type_,
_ => return list_default(defaults, field),
};
scalar.clone()
}
fn list_default(defaults: &TemplateDefaults, field: &str) -> Option<String> {
let values = match field {
"labels" => &defaults.labels,
"files" => &defaults.files,
"cycles" => &defaults.cycles,
"milestones" => &defaults.milestones,
"related_issues" => &defaults.related_issues,
_ => return None,
};
let joined = values
.iter()
.filter(|value| !value.trim().is_empty())
.cloned()
.collect::<Vec<_>>()
.join(",");
if joined.is_empty() {
None
} else {
Some(joined)
}
}
fn non_blank(value: &Option<String>) -> Option<String> {
value
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
impl MergedFields {
fn owned(&self, field: &str) -> Option<String> {
self.get(field).map(str::to_string)
}
}
fn build_request(merged: &MergedFields) -> CreateRequest {
CreateRequest {
title: merged.get("title").unwrap_or_default().trim().to_string(),
body: merged.owned("body"),
status: merged.owned("status").map(Status::new),
priority: merged
.get("priority")
.and_then(|raw| crate::commands::parse_priority(raw.trim()).ok()),
assignee: merged.owned("assignee"),
labels: merged.owned("labels"),
files: merged.owned("files"),
estimate: merged.owned("estimate"),
cycles: merged.owned("cycles"),
milestones: merged.owned("milestones"),
reviewer: merged.owned("reviewer"),
start_date: merged.owned("start_date"),
due_date: merged.owned("due_date"),
parent: merged.owned("parent"),
related_issues: merged.owned("related_issues"),
type_: merged.owned("type"),
}
}