use std::fmt;
use std::path::Path;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::templates::registry::{
Template, TemplateDefaults, TemplateMetadata, TemplatePrompt, TemplateReference,
TemplateRegistry, TemplateScope,
};
pub type TemplateMutation<T> = std::result::Result<TemplateOutcome<T>, TemplateWriteError>;
#[derive(Debug)]
pub enum TemplateWriteError {
Refused(TixError),
Failed(TixError),
}
impl TemplateWriteError {
#[must_use]
pub fn inner(&self) -> &TixError {
match self {
Self::Refused(error) | Self::Failed(error) => error,
}
}
#[must_use]
pub fn into_inner(self) -> TixError {
match self {
Self::Refused(error) | Self::Failed(error) => error,
}
}
#[must_use]
pub const fn is_refusal(&self) -> bool {
matches!(self, Self::Refused(_))
}
}
impl From<TixError> for TemplateWriteError {
fn from(error: TixError) -> Self {
Self::Refused(error)
}
}
impl fmt::Display for TemplateWriteError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Refused(error) => write!(f, "template refused: {error}"),
Self::Failed(error) => write!(f, "template write failed: {error}"),
}
}
}
impl std::error::Error for TemplateWriteError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(self.inner())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TemplateMutationKind {
Create,
Update,
Duplicate,
Delete,
}
impl TemplateMutationKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Create => "create",
Self::Update => "update",
Self::Duplicate => "duplicate",
Self::Delete => "delete",
}
}
}
impl fmt::Display for TemplateMutationKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateAuditEvent {
kind: TemplateMutationKind,
template_id: String,
template_name: String,
}
impl TemplateAuditEvent {
fn new(kind: TemplateMutationKind, template_id: String, template_name: String) -> Self {
Self {
kind,
template_id,
template_name,
}
}
#[must_use]
pub const fn kind(&self) -> TemplateMutationKind {
self.kind
}
#[must_use]
pub fn template_id(&self) -> &str {
&self.template_id
}
#[must_use]
pub fn template_name(&self) -> &str {
&self.template_name
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TemplateOutcome<T> {
value: T,
audit: Option<TemplateAuditEvent>,
}
impl<T> TemplateOutcome<T> {
fn accepted(value: T, audit: TemplateAuditEvent) -> Self {
Self {
value,
audit: Some(audit),
}
}
fn unchanged(value: T) -> Self {
Self { value, audit: None }
}
#[must_use]
pub fn value(&self) -> &T {
&self.value
}
#[must_use]
pub fn into_value(self) -> T {
self.value
}
#[must_use]
pub fn audit(&self) -> Option<&TemplateAuditEvent> {
self.audit.as_ref()
}
#[must_use]
pub fn is_noop(&self) -> bool {
self.audit.is_none()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DeleteConfirmed(());
impl DeleteConfirmed {
#[must_use]
pub const fn confirmed() -> Self {
Self(())
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct TemplateDefinition {
name: Option<String>,
scope: Option<TemplateScope>,
defaults: Option<TemplateDefaults>,
prompts: Option<Vec<TemplatePrompt>>,
references: Option<Vec<TemplateReference>>,
}
impl TemplateDefinition {
#[must_use]
pub fn new(name: impl Into<String>, scope: TemplateScope) -> Self {
Self {
name: Some(name.into()),
scope: Some(scope),
..Self::default()
}
}
#[must_use]
pub fn unnamed(scope: TemplateScope) -> Self {
Self {
scope: Some(scope),
..Self::default()
}
}
#[must_use]
pub fn rename(name: impl Into<String>) -> Self {
Self {
name: Some(name.into()),
..Self::default()
}
}
#[must_use]
pub fn with_defaults(mut self, defaults: TemplateDefaults) -> Self {
self.defaults = Some(defaults);
self
}
#[must_use]
pub fn with_prompts(mut self, prompts: Vec<TemplatePrompt>) -> Self {
self.prompts = Some(prompts);
self
}
#[must_use]
pub fn with_references(mut self, references: Vec<TemplateReference>) -> Self {
self.references = Some(references);
self
}
fn into_template(self) -> Template {
Template {
name: self.name,
scope: self.scope.unwrap_or_default(),
defaults: self.defaults.unwrap_or_default(),
prompts: self.prompts.unwrap_or_default(),
references: self.references.unwrap_or_default(),
..Template::default()
}
}
fn apply_to(self, template: &mut Template) {
if let Some(name) = self.name {
template.name = Some(name);
}
if let Some(scope) = self.scope {
template.scope = scope;
}
if let Some(defaults) = self.defaults {
template.defaults = defaults;
}
if let Some(prompts) = self.prompts {
template.prompts = prompts;
}
if let Some(references) = self.references {
template.references = references;
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TemplateDetail {
id: String,
template: Template,
}
impl TemplateDetail {
fn new(id: String, template: Template) -> Self {
Self { id, template }
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn template(&self) -> &Template {
&self.template
}
#[must_use]
pub fn display_name(&self) -> &str {
self.template.display_name(&self.id)
}
#[must_use]
pub fn scope(&self) -> &TemplateScope {
&self.template.scope
}
#[must_use]
pub fn defaults(&self) -> &TemplateDefaults {
&self.template.defaults
}
#[must_use]
pub fn prompts(&self) -> &[TemplatePrompt] {
&self.template.prompts
}
#[must_use]
pub fn references(&self) -> &[TemplateReference] {
&self.template.references
}
#[must_use]
pub fn extensions(&self) -> &TemplateMetadata {
&self.template.extensions
}
}
pub struct TemplateService<'a> {
config: &'a mut Config,
path: &'a Path,
}
impl<'a> TemplateService<'a> {
#[must_use]
pub fn new(config: &'a mut Config, path: &'a Path) -> Self {
Self { config, path }
}
#[must_use]
pub fn registry(&self) -> &TemplateRegistry {
&self.config.issue_templates
}
#[must_use]
pub fn list(&self) -> Vec<TemplateDetail> {
self.config
.issue_templates
.iter()
.map(|(id, template)| TemplateDetail::new(id.to_string(), template.clone()))
.collect()
}
pub fn show(&self, reference: &str) -> Result<TemplateDetail> {
let (id, template) = self.select(reference)?;
Ok(TemplateDetail::new(id, template))
}
pub fn create(&mut self, id: &str, definition: TemplateDefinition) -> TemplateMutation<String> {
let mut candidate = self.config.issue_templates.clone();
candidate
.insert(id, definition.into_template())
.map_err(TemplateWriteError::Refused)?;
let id = id.to_string();
let name = display_name_of(&candidate, &id);
self.commit(candidate)?;
Ok(TemplateOutcome::accepted(
id.clone(),
TemplateAuditEvent::new(TemplateMutationKind::Create, id, name),
))
}
pub fn update(
&mut self,
reference: &str,
definition: TemplateDefinition,
) -> TemplateMutation<String> {
let (id, _source) = self.select(reference)?;
let mut candidate = self.config.issue_templates.clone();
let template = candidate
.get_mut(&id)
.expect("the selected template is in the registry");
definition.apply_to(template);
if candidate == self.config.issue_templates {
return Ok(TemplateOutcome::unchanged(id));
}
let name = display_name_of(&candidate, &id);
self.commit(candidate)?;
Ok(TemplateOutcome::accepted(
id.clone(),
TemplateAuditEvent::new(TemplateMutationKind::Update, id, name),
))
}
pub fn duplicate(
&mut self,
reference: &str,
new_id: Option<&str>,
name: Option<&str>,
) -> TemplateMutation<TemplateDetail> {
let (source_id, source) = self.select(reference)?;
let target_id = match new_id {
Some(id) => id.to_string(),
None => self.allocate_duplicate_id(&source_id),
};
let mut copy = source;
if let Some(name) = name {
copy.name = Some(name.trim().to_string());
}
let mut candidate = self.config.issue_templates.clone();
candidate
.insert(target_id.clone(), copy)
.map_err(TemplateWriteError::Refused)?;
let detail = TemplateDetail::new(
target_id.clone(),
candidate_template(&candidate, &target_id),
);
let name = detail.display_name().to_string();
self.commit(candidate)?;
Ok(TemplateOutcome::accepted(
detail,
TemplateAuditEvent::new(TemplateMutationKind::Duplicate, target_id, name),
))
}
pub fn delete(
&mut self,
reference: &str,
_confirmation: DeleteConfirmed,
) -> TemplateMutation<TemplateDetail> {
let (id, _source) = self.select(reference)?;
let mut candidate = self.config.issue_templates.clone();
let removed = candidate
.remove(&id)
.expect("the selected template is in the registry");
let detail = TemplateDetail::new(id.clone(), removed);
let name = detail.display_name().to_string();
self.commit(candidate)?;
Ok(TemplateOutcome::accepted(
detail,
TemplateAuditEvent::new(TemplateMutationKind::Delete, id, name),
))
}
fn select(&self, reference: &str) -> Result<(String, Template)> {
if let Some(template) = self.config.issue_templates.get(reference) {
return Ok((reference.to_string(), template.clone()));
}
let matches: Vec<(&str, &Template)> = self
.config
.issue_templates
.iter()
.filter(|(_, template)| template.name.as_deref() == Some(reference))
.collect();
match matches.as_slice() {
[] => Err(TixError::NotFound(format!(
"no template with id or name `{reference}`"
))),
[(id, template)] => Ok(((*id).to_string(), (*template).clone())),
many => {
let ids: Vec<&str> = many.iter().map(|(id, _)| *id).collect();
Err(TixError::Config(format!(
"template name `{reference}` is ambiguous; it matches ids {}",
ids.join(", ")
)))
}
}
}
#[must_use]
fn allocate_duplicate_id(&self, source: &str) -> String {
let registry = &self.config.issue_templates;
let first = format!("{source}-copy");
if !registry.contains(&first) {
return first;
}
let mut suffix = 2u32;
loop {
let candidate = format!("{source}-copy-{suffix}");
if !registry.contains(&candidate) {
return candidate;
}
suffix += 1;
}
}
fn commit(&mut self, next: TemplateRegistry) -> std::result::Result<(), TemplateWriteError> {
let mut candidate = self.config.clone();
candidate.issue_templates = next;
candidate.validate().map_err(TemplateWriteError::Refused)?;
candidate
.save(self.path)
.map_err(TemplateWriteError::Failed)?;
*self.config = candidate;
Ok(())
}
}
fn display_name_of(registry: &TemplateRegistry, id: &str) -> String {
registry.get(id).map_or_else(
|| id.to_string(),
|template| template.display_name(id).to_string(),
)
}
fn candidate_template(registry: &TemplateRegistry, id: &str) -> Template {
registry
.get(id)
.expect("the just-inserted template is in the registry")
.clone()
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::config::Config;
use std::fs;
fn fixture(raw: &str) -> (tempfile::TempDir, Config) {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("tix.toml");
fs::write(&path, raw).unwrap();
let config = Config::load(dir.path()).unwrap();
(dir, config)
}
fn definition(name: &str) -> TemplateDefinition {
TemplateDefinition::new(name, TemplateScope::repository())
}
#[test]
fn create_then_show_round_trips_and_reloads() {
let (dir, mut config) = fixture("issues_dir = \"docs/issues\"\n");
let path = dir.path().join("tix.toml");
let mut service = TemplateService::new(&mut config, &path);
let outcome = service.create("bug", definition("Bug report")).unwrap();
assert_eq!(
outcome.audit().unwrap().kind(),
TemplateMutationKind::Create
);
assert_eq!(outcome.value(), "bug");
let detail = service.show("bug").unwrap();
assert_eq!(detail.id(), "bug");
assert_eq!(detail.display_name(), "Bug report");
assert!(detail.scope().is_repository());
let reloaded = Config::load(dir.path()).unwrap();
assert_eq!(
reloaded.issue_templates.get("bug").unwrap().name.as_deref(),
Some("Bug report")
);
}
#[test]
fn a_same_content_update_is_a_noop() {
let (dir, mut config) = fixture("issues_dir = \"docs/issues\"\n");
let path = dir.path().join("tix.toml");
let mut service = TemplateService::new(&mut config, &path);
service.create("bug", definition("Bug report")).unwrap();
let outcome = service.update("bug", definition("Bug report")).unwrap();
assert!(outcome.is_noop());
}
}