1use std::fmt;
36
37use crate::config::Config;
38use crate::model::{Priority, Status};
39
40use super::registry::{
41 CREATE_FIELDS, Template, TemplateDefaults, TemplateScope, TemplateScopeKind,
42};
43use super::validation::{self, DiagnosticKind, TemplateDiagnostic};
44
45#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct TemplateSelection {
52 pub id: String,
54 pub scope: TemplateScope,
56}
57
58impl TemplateSelection {
59 pub fn new(id: impl Into<String>, scope: TemplateScope) -> Self {
61 Self {
62 id: id.into(),
63 scope,
64 }
65 }
66}
67
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
76pub struct TemplateInput {
77 pub title: Option<String>,
79 pub body: Option<String>,
81 pub status: Option<String>,
83 pub priority: Option<String>,
85 pub assignee: Option<String>,
87 pub labels: Option<String>,
89 pub files: Option<String>,
91 pub estimate: Option<String>,
93 pub cycles: Option<String>,
95 pub milestones: Option<String>,
97 pub reviewer: Option<String>,
99 pub start_date: Option<String>,
101 pub due_date: Option<String>,
103 pub parent: Option<String>,
105 pub related_issues: Option<String>,
107 pub type_: Option<String>,
109}
110
111impl TemplateInput {
112 #[must_use]
115 pub fn get(&self, field: &str) -> Option<&str> {
116 match field {
117 "title" => self.title.as_deref(),
118 "body" => self.body.as_deref(),
119 "status" => self.status.as_deref(),
120 "priority" => self.priority.as_deref(),
121 "assignee" => self.assignee.as_deref(),
122 "labels" => self.labels.as_deref(),
123 "files" => self.files.as_deref(),
124 "estimate" => self.estimate.as_deref(),
125 "cycles" => self.cycles.as_deref(),
126 "milestones" => self.milestones.as_deref(),
127 "reviewer" => self.reviewer.as_deref(),
128 "start_date" => self.start_date.as_deref(),
129 "due_date" => self.due_date.as_deref(),
130 "parent" => self.parent.as_deref(),
131 "related_issues" => self.related_issues.as_deref(),
132 "type" => self.type_.as_deref(),
133 _ => None,
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct CreateRequest {
147 pub title: String,
149 pub body: Option<String>,
151 pub status: Option<Status>,
153 pub priority: Option<Priority>,
155 pub assignee: Option<String>,
157 pub labels: Option<String>,
159 pub files: Option<String>,
161 pub estimate: Option<String>,
163 pub cycles: Option<String>,
165 pub milestones: Option<String>,
167 pub reviewer: Option<String>,
169 pub start_date: Option<String>,
171 pub due_date: Option<String>,
173 pub parent: Option<String>,
175 pub related_issues: Option<String>,
177 pub type_: Option<String>,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct TemplateResolution {
188 template_id: String,
189 scope: TemplateScope,
190 provided_fields: Vec<String>,
191 diagnostics: Vec<TemplateDiagnostic>,
192 request: Option<CreateRequest>,
193}
194
195impl TemplateResolution {
196 #[must_use]
198 pub fn template_id(&self) -> &str {
199 &self.template_id
200 }
201
202 #[must_use]
204 pub fn scope(&self) -> &TemplateScope {
205 &self.scope
206 }
207
208 #[must_use]
210 pub fn provided_fields(&self) -> &[String] {
211 &self.provided_fields
212 }
213
214 #[must_use]
218 pub fn diagnostics(&self) -> &[TemplateDiagnostic] {
219 &self.diagnostics
220 }
221
222 #[must_use]
224 pub fn request(&self) -> Option<&CreateRequest> {
225 self.request.as_ref()
226 }
227
228 #[must_use]
230 pub fn is_ok(&self) -> bool {
231 self.request.is_some()
232 }
233
234 #[must_use]
236 pub fn into_request(self) -> Option<CreateRequest> {
237 self.request
238 }
239
240 fn rejected(
242 template_id: &str,
243 scope: &TemplateScope,
244 diagnostics: Vec<TemplateDiagnostic>,
245 ) -> Self {
246 Self {
247 template_id: template_id.to_string(),
248 scope: scope.clone(),
249 provided_fields: Vec::new(),
250 diagnostics,
251 request: None,
252 }
253 }
254}
255
256impl fmt::Display for TemplateResolution {
257 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258 if self.is_ok() {
259 write!(
260 f,
261 "template `{}` at scope `{}` resolved",
262 self.template_id, self.scope
263 )
264 } else {
265 write!(
266 f,
267 "template `{}` at scope `{}` did not resolve ({} diagnostic(s))",
268 self.template_id,
269 self.scope,
270 self.diagnostics.len()
271 )
272 }
273 }
274}
275
276#[must_use]
294pub fn resolve(
295 config: &Config,
296 selection: &TemplateSelection,
297 input: &TemplateInput,
298) -> TemplateResolution {
299 let id = selection.id.trim().to_string();
300 if id.is_empty() {
301 return TemplateResolution::rejected(
302 &id,
303 &selection.scope,
304 vec![TemplateDiagnostic::new(
305 DiagnosticKind::UnknownTemplate,
306 "template_id",
307 "a template id is required (a display name is not identity)",
308 )],
309 );
310 }
311 let Some(template) = config.issue_templates.get(&id) else {
312 return TemplateResolution::rejected(
313 &id,
314 &selection.scope,
315 vec![TemplateDiagnostic::new(
316 DiagnosticKind::UnknownTemplate,
317 "template_id",
318 unknown_template_message(config, &id),
319 )],
320 );
321 };
322 if let Err(error) = template.validate(&id, config) {
326 return TemplateResolution::rejected(
327 &id,
328 &selection.scope,
329 vec![TemplateDiagnostic::new(
330 DiagnosticKind::InvalidValue,
331 "template",
332 error.to_string(),
333 )],
334 );
335 }
336 let scope_diagnostics = scope_diagnostics(config, &id, selection, template);
337 if !scope_diagnostics.is_empty() {
338 return TemplateResolution::rejected(&id, &selection.scope, scope_diagnostics);
339 }
340 let merged = merge(template, input);
341 let mut diagnostics = validation::missing_required(template, &merged);
342 diagnostics.extend(validation::invalid_values(&merged));
343 diagnostics.extend(validation::validate_references(config, template, &merged));
344 let provided_fields = merged
345 .iter()
346 .map(|(field, _)| field.to_string())
347 .collect::<Vec<_>>();
348 let request = diagnostics.is_empty().then(|| build_request(&merged));
349 TemplateResolution {
350 template_id: id,
351 scope: selection.scope.clone(),
352 provided_fields,
353 diagnostics,
354 request,
355 }
356}
357
358fn unknown_template_message(config: &Config, id: &str) -> String {
361 if let Some((stable, _)) = config
362 .issue_templates
363 .iter()
364 .find(|(_, template)| template.display_name("") == id)
365 {
366 return format!("`{id}` is a display name, not a stable id; use `{stable}`");
367 }
368 let known = config.issue_templates.ids().collect::<Vec<_>>();
369 let list = if known.is_empty() {
370 "none configured".to_string()
371 } else {
372 known.join(", ")
373 };
374 format!("unknown template id `{id}` — known ids: {list}")
375}
376
377fn scope_diagnostics(
379 config: &Config,
380 id: &str,
381 selection: &TemplateSelection,
382 template: &Template,
383) -> Vec<TemplateDiagnostic> {
384 let requested = &selection.scope;
385 let mut out = Vec::new();
386 let qualifier = requested.qualifier().unwrap_or_default().trim();
387 match requested.kind() {
388 TemplateScopeKind::Repository => {}
389 TemplateScopeKind::Board if qualifier.is_empty() => out.push(TemplateDiagnostic::new(
390 DiagnosticKind::InvalidScope,
391 "scope",
392 "a board scope must name its board as `board:<name>`",
393 )),
394 TemplateScopeKind::Board => {
395 if !config.kanban.configurations.contains_key(qualifier) {
396 out.push(TemplateDiagnostic::new(
397 DiagnosticKind::InvalidScope,
398 "scope",
399 format!("references unknown board `{qualifier}`"),
400 ));
401 }
402 }
403 TemplateScopeKind::Team if qualifier.is_empty() => out.push(TemplateDiagnostic::new(
404 DiagnosticKind::InvalidScope,
405 "scope",
406 "a team scope must name its team as `team:<label>`",
407 )),
408 TemplateScopeKind::Team => {
409 if !config.teams.contains_key(qualifier) {
410 out.push(TemplateDiagnostic::new(
411 DiagnosticKind::InvalidScope,
412 "scope",
413 format!("references unknown team `{qualifier}`"),
414 ));
415 }
416 }
417 }
418 if out.is_empty() && requested != &template.scope {
419 out.push(TemplateDiagnostic::new(
420 DiagnosticKind::ScopeMismatch,
421 "scope",
422 format!(
423 "template `{id}` is scoped to `{}`, not `{}`",
424 template.scope, requested
425 ),
426 ));
427 }
428 out
429}
430
431#[derive(Debug, Clone, Default, PartialEq, Eq)]
434pub(crate) struct MergedFields {
435 entries: Vec<(&'static str, String)>,
436}
437
438impl MergedFields {
439 fn set(&mut self, field: &'static str, value: String) {
442 self.entries.push((field, value));
443 }
444
445 pub(crate) fn get(&self, field: &str) -> Option<&str> {
448 self.entries
449 .iter()
450 .find(|(key, _)| *key == field)
451 .map(|(_, value)| value.as_str())
452 }
453
454 pub(crate) fn is_blank(&self, field: &str) -> bool {
456 self.get(field).is_none_or(|value| value.trim().is_empty())
457 }
458
459 pub(crate) fn iter(&self) -> impl Iterator<Item = (&'static str, &str)> {
461 self.entries
462 .iter()
463 .map(|(field, value)| (*field, value.as_str()))
464 }
465}
466
467fn merge(template: &Template, input: &TemplateInput) -> MergedFields {
470 let mut merged = MergedFields::default();
471 for field in CREATE_FIELDS {
472 let value = input
473 .get(field)
474 .map(str::to_string)
475 .or_else(|| prompt_default(template, field))
476 .or_else(|| defaults_value(&template.defaults, field));
477 if let Some(value) = value {
478 merged.set(field, value);
479 }
480 }
481 merged
482}
483
484fn prompt_default(template: &Template, field: &str) -> Option<String> {
486 template
487 .prompts
488 .iter()
489 .find(|prompt| prompt.key == field)
490 .and_then(|prompt| non_blank(&prompt.default))
491}
492
493fn defaults_value(defaults: &TemplateDefaults, field: &str) -> Option<String> {
496 let scalar = match field {
497 "title" => &defaults.title,
498 "body" => &defaults.body,
499 "status" => &defaults.status,
500 "priority" => &defaults.priority,
501 "assignee" => &defaults.assignee,
502 "estimate" => &defaults.estimate,
503 "reviewer" => &defaults.reviewer,
504 "start_date" => &defaults.start_date,
505 "due_date" => &defaults.due_date,
506 "parent" => &defaults.parent,
507 "type" => &defaults.type_,
508 _ => return list_default(defaults, field),
509 };
510 scalar.clone()
511}
512
513fn list_default(defaults: &TemplateDefaults, field: &str) -> Option<String> {
515 let values = match field {
516 "labels" => &defaults.labels,
517 "files" => &defaults.files,
518 "cycles" => &defaults.cycles,
519 "milestones" => &defaults.milestones,
520 "related_issues" => &defaults.related_issues,
521 _ => return None,
522 };
523 let joined = values
524 .iter()
525 .filter(|value| !value.trim().is_empty())
526 .cloned()
527 .collect::<Vec<_>>()
528 .join(",");
529 if joined.is_empty() {
530 None
531 } else {
532 Some(joined)
533 }
534}
535
536fn non_blank(value: &Option<String>) -> Option<String> {
538 value
539 .as_deref()
540 .map(str::trim)
541 .filter(|value| !value.is_empty())
542 .map(str::to_string)
543}
544
545impl MergedFields {
547 fn owned(&self, field: &str) -> Option<String> {
548 self.get(field).map(str::to_string)
549 }
550}
551
552fn build_request(merged: &MergedFields) -> CreateRequest {
558 CreateRequest {
559 title: merged.get("title").unwrap_or_default().trim().to_string(),
560 body: merged.owned("body"),
561 status: merged.owned("status").map(Status::new),
562 priority: merged
563 .get("priority")
564 .and_then(|raw| crate::commands::parse_priority(raw.trim()).ok()),
565 assignee: merged.owned("assignee"),
566 labels: merged.owned("labels"),
567 files: merged.owned("files"),
568 estimate: merged.owned("estimate"),
569 cycles: merged.owned("cycles"),
570 milestones: merged.owned("milestones"),
571 reviewer: merged.owned("reviewer"),
572 start_date: merged.owned("start_date"),
573 due_date: merged.owned("due_date"),
574 parent: merged.owned("parent"),
575 related_issues: merged.owned("related_issues"),
576 type_: merged.owned("type"),
577 }
578}