use std::fmt::Write as _;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use crate::cli::TemplateAction;
use crate::commands::create;
use crate::commands::parse_csv_list;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::storage::Store;
use crate::templates::registry::{
CREATE_FIELDS, PromptKind, ReferenceKind, TemplateDefaults, TemplatePrompt, TemplateReference,
TemplateScope,
};
use crate::templates::resolver::{
CreateRequest, TemplateInput, TemplateResolution, TemplateSelection, resolve,
};
use crate::templates::{
DeleteConfirmed, TemplateDefinition, TemplateDetail, TemplateService, TemplateWriteError,
};
pub fn run(cwd: &Path, action: &TemplateAction) -> Result<()> {
let (mut config, path) = load_management_config(cwd)?;
match action {
TemplateAction::List => {
let service = TemplateService::new(&mut config, &path);
print!("{}", render_list(&service.list()));
Ok(())
}
TemplateAction::Show { reference } => {
let service = TemplateService::new(&mut config, &path);
print!("{}", render_detail(&service.show(reference)?));
Ok(())
}
TemplateAction::Create {
id,
name,
scope,
defaults,
prompts,
references,
} => {
let definition = definition_for_create(
name.as_deref(),
scope.as_deref(),
defaults,
prompts,
references,
)?;
let mut service = TemplateService::new(&mut config, &path);
let outcome = service
.create(id, definition)
.map_err(TemplateWriteError::into_inner)?;
println!("created template `{}`", outcome.value());
Ok(())
}
TemplateAction::Update {
reference,
name,
scope,
defaults,
prompts,
references,
} => {
let definition = definition_for_update(
name.as_deref(),
scope.as_deref(),
defaults,
prompts,
references,
)?;
let mut service = TemplateService::new(&mut config, &path);
let outcome = service
.update(reference, definition)
.map_err(TemplateWriteError::into_inner)?;
if outcome.is_noop() {
println!("unchanged template `{}`", outcome.value());
} else {
println!("updated template `{}`", outcome.value());
}
Ok(())
}
TemplateAction::Duplicate {
reference,
id,
name,
} => {
let mut service = TemplateService::new(&mut config, &path);
let outcome = service
.duplicate(reference, id.as_deref(), name.as_deref())
.map_err(TemplateWriteError::into_inner)?;
println!(
"duplicated template `{reference}` as `{}`",
outcome.value().id()
);
Ok(())
}
TemplateAction::Delete { reference, yes } => {
confirm_delete(reference, *yes)?;
let mut service = TemplateService::new(&mut config, &path);
let outcome = service
.delete(reference, DeleteConfirmed::confirmed())
.map_err(TemplateWriteError::into_inner)?;
println!("deleted template `{}`", outcome.value().id());
Ok(())
}
}
}
fn load_management_config(cwd: &Path) -> Result<(Config, PathBuf)> {
let root = Config::project_root(cwd)
.ok_or_else(|| TixError::Config("no tix.toml found — run \"tix init\" first".into()))?;
let config = Config::load(&root)?;
Ok((config, root.join("tix.toml")))
}
fn confirm_delete(reference: &str, yes: bool) -> Result<()> {
let mut confirmed = yes || !std::io::stdin().is_terminal();
if !confirmed {
eprint!("Delete template '{reference}'? [y/N] ");
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(TixError::Io)?;
confirmed = line.trim().eq_ignore_ascii_case("y");
}
if !confirmed {
return Err(TixError::InvalidInput(format!(
"deletion of template `{reference}` was not confirmed"
)));
}
Ok(())
}
fn definition_for_create(
name: Option<&str>,
scope: Option<&str>,
defaults: &[String],
prompts: &[String],
references: &[String],
) -> Result<TemplateDefinition> {
let scope = match scope {
Some(raw) => parse_scope(raw)?,
None => TemplateScope::repository(),
};
let mut definition = match name {
Some(name) => TemplateDefinition::new(name, scope),
None => TemplateDefinition::unnamed(scope),
};
if !defaults.is_empty() {
definition = definition.with_defaults(build_defaults(defaults)?);
}
if !prompts.is_empty() {
definition = definition.with_prompts(build_prompts(prompts)?);
}
if !references.is_empty() {
definition = definition.with_references(build_references(references)?);
}
Ok(definition)
}
fn definition_for_update(
name: Option<&str>,
scope: Option<&str>,
defaults: &[String],
prompts: &[String],
references: &[String],
) -> Result<TemplateDefinition> {
let mut definition = match (name, scope) {
(Some(name), Some(raw)) => TemplateDefinition::new(name, parse_scope(raw)?),
(Some(name), None) => TemplateDefinition::rename(name),
(None, Some(raw)) => TemplateDefinition::unnamed(parse_scope(raw)?),
(None, None) => TemplateDefinition::default(),
};
if !defaults.is_empty() {
definition = definition.with_defaults(build_defaults(defaults)?);
}
if !prompts.is_empty() {
definition = definition.with_prompts(build_prompts(prompts)?);
}
if !references.is_empty() {
definition = definition.with_references(build_references(references)?);
}
Ok(definition)
}
fn parse_scope(raw: &str) -> Result<TemplateScope> {
raw.parse::<TemplateScope>().map_err(TixError::InvalidInput)
}
fn build_defaults(specs: &[String]) -> Result<TemplateDefaults> {
let mut defaults = TemplateDefaults::default();
for spec in specs {
let Some((field, value)) = spec.split_once('=') else {
return Err(TixError::InvalidInput(format!(
"--default `{spec}` must be `FIELD=VALUE`"
)));
};
let field = field.trim();
let value = value.trim().to_string();
match field {
"title" => defaults.title = Some(value),
"body" => defaults.body = Some(value),
"status" => defaults.status = Some(value),
"priority" => defaults.priority = Some(value),
"assignee" => defaults.assignee = Some(value),
"labels" => defaults.labels = parse_csv_list(&value),
"files" => defaults.files = parse_csv_list(&value),
"estimate" => defaults.estimate = Some(value),
"cycles" => defaults.cycles = parse_csv_list(&value),
"milestones" => defaults.milestones = parse_csv_list(&value),
"reviewer" => defaults.reviewer = Some(value),
"start_date" => defaults.start_date = Some(value),
"due_date" => defaults.due_date = Some(value),
"parent" => defaults.parent = Some(value),
"related_issues" => defaults.related_issues = parse_csv_list(&value),
"type" => defaults.type_ = Some(value),
other => {
return Err(TixError::InvalidInput(format!(
"`{other}` is not a create field; expected one of {}",
CREATE_FIELDS.join(", ")
)));
}
}
}
Ok(defaults)
}
fn build_prompts(specs: &[String]) -> Result<Vec<TemplatePrompt>> {
specs.iter().map(|spec| parse_prompt(spec)).collect()
}
fn parse_prompt(raw: &str) -> Result<TemplatePrompt> {
let mut key: Option<String> = None;
let mut label: Option<String> = None;
let mut kind = PromptKind::Text;
let mut required = false;
let mut default: Option<String> = None;
let mut options_ref: Option<String> = None;
for (name, value) in split_attributes(raw, "--prompt")? {
match name {
"key" => key = Some(value.to_string()),
"kind" => kind = parse_prompt_kind(value)?,
"required" => required = parse_bool(value, "--prompt required")?,
"default" => default = Some(value.to_string()),
"label" => label = Some(value.to_string()),
"options_ref" => options_ref = Some(value.to_string()),
other => {
return Err(TixError::InvalidInput(format!(
"unknown --prompt attribute `{other}`; expected key, kind, required, \
default, label or options_ref"
)));
}
}
}
let key = key.ok_or_else(|| {
TixError::InvalidInput("--prompt requires `key=<create field>`".to_string())
})?;
Ok(TemplatePrompt {
key,
label,
kind,
required,
default,
options_ref,
})
}
fn build_references(specs: &[String]) -> Result<Vec<TemplateReference>> {
specs.iter().map(|spec| parse_reference(spec)).collect()
}
fn parse_reference(raw: &str) -> Result<TemplateReference> {
let mut kind: Option<ReferenceKind> = None;
let mut value: Option<String> = None;
let mut required = false;
for (name, raw_value) in split_attributes(raw, "--reference")? {
match name {
"kind" => kind = Some(parse_reference_kind(raw_value)?),
"value" => value = Some(raw_value.to_string()),
"required" => required = parse_bool(raw_value, "--reference required")?,
other => {
return Err(TixError::InvalidInput(format!(
"unknown --reference attribute `{other}`; expected kind, value or required"
)));
}
}
}
let kind = kind
.ok_or_else(|| TixError::InvalidInput("--reference requires `kind=<kind>`".to_string()))?;
let value = value.ok_or_else(|| {
TixError::InvalidInput("--reference requires `value=<value>`".to_string())
})?;
Ok(TemplateReference {
kind,
value,
required,
})
}
fn split_attributes<'a>(raw: &'a str, flag: &str) -> Result<Vec<(&'a str, &'a str)>> {
let mut out = Vec::new();
for token in raw.split(',') {
let token = token.trim();
if token.is_empty() {
continue;
}
let Some((name, value)) = token.split_once('=') else {
return Err(TixError::InvalidInput(format!(
"{flag} attribute `{token}` must be `key=value`"
)));
};
out.push((name.trim(), value.trim()));
}
Ok(out)
}
fn parse_bool(raw: &str, what: &str) -> Result<bool> {
match raw {
"true" => Ok(true),
"false" => Ok(false),
other => Err(TixError::InvalidInput(format!(
"{what} must be `true` or `false`, got `{other}`"
))),
}
}
fn parse_prompt_kind(raw: &str) -> Result<PromptKind> {
match raw {
"text" => Ok(PromptKind::Text),
"select" => Ok(PromptKind::Select),
"multi-select" => Ok(PromptKind::MultiSelect),
"date" => Ok(PromptKind::Date),
other => Err(TixError::InvalidInput(format!(
"unknown prompt kind `{other}`; expected text, select, multi-select or date"
))),
}
}
fn parse_reference_kind(raw: &str) -> Result<ReferenceKind> {
match raw {
"label" => Ok(ReferenceKind::Label),
"assignee" => Ok(ReferenceKind::Assignee),
"file" => Ok(ReferenceKind::File),
"cycle" => Ok(ReferenceKind::Cycle),
"milestone" => Ok(ReferenceKind::Milestone),
"relationship" => Ok(ReferenceKind::Relationship),
other => Err(TixError::InvalidInput(format!(
"unknown reference kind `{other}`; expected label, assignee, file, cycle, \
milestone or relationship"
))),
}
}
pub fn render_list(details: &[TemplateDetail]) -> String {
let mut out = String::new();
for detail in details {
let _ = writeln!(
out,
"{}\t{}\t{}",
detail.id(),
detail.display_name(),
detail.scope()
);
}
out
}
pub fn render_detail(detail: &TemplateDetail) -> String {
let mut out = String::new();
let _ = writeln!(out, "id: {}", detail.id());
let _ = writeln!(out, "name: {}", detail.display_name());
let _ = writeln!(out, "scope: {}", detail.scope());
out.push_str(&render_defaults(detail.defaults()));
for (index, prompt) in detail.prompts().iter().enumerate() {
let _ = writeln!(out, "prompt[{index}].key: {}", prompt.key);
if let Some(label) = &prompt.label {
let _ = writeln!(out, "prompt[{index}].label: {label}");
}
let _ = writeln!(
out,
"prompt[{index}].kind: {}",
prompt_kind_str(prompt.kind)
);
let _ = writeln!(out, "prompt[{index}].required: {}", prompt.required);
if let Some(default) = &prompt.default {
let _ = writeln!(out, "prompt[{index}].default: {default}");
}
if let Some(options_ref) = &prompt.options_ref {
let _ = writeln!(out, "prompt[{index}].options_ref: {options_ref}");
}
}
for (index, reference) in detail.references().iter().enumerate() {
let _ = writeln!(
out,
"reference[{index}].kind: {}",
reference_kind_str(reference.kind)
);
let _ = writeln!(out, "reference[{index}].value: {}", reference.value);
let _ = writeln!(out, "reference[{index}].required: {}", reference.required);
}
for (key, value) in detail.extensions().iter() {
let _ = writeln!(out, "unknown.{key}: {value}");
}
out
}
fn render_defaults(defaults: &TemplateDefaults) -> String {
let mut out = String::new();
for (field, value) in [
("title", defaults.title.as_deref()),
("body", defaults.body.as_deref()),
("status", defaults.status.as_deref()),
("priority", defaults.priority.as_deref()),
("assignee", defaults.assignee.as_deref()),
("estimate", defaults.estimate.as_deref()),
("reviewer", defaults.reviewer.as_deref()),
("start_date", defaults.start_date.as_deref()),
("due_date", defaults.due_date.as_deref()),
("parent", defaults.parent.as_deref()),
("type", defaults.type_.as_deref()),
] {
if let Some(value) = value {
let _ = writeln!(out, "defaults.{field}: {value}");
}
}
for (field, values) in [
("labels", &defaults.labels),
("files", &defaults.files),
("cycles", &defaults.cycles),
("milestones", &defaults.milestones),
("related_issues", &defaults.related_issues),
] {
if !values.is_empty() {
let _ = writeln!(out, "defaults.{field}: {}", values.join(", "));
}
}
out
}
fn prompt_kind_str(kind: PromptKind) -> &'static str {
match kind {
PromptKind::Text => "text",
PromptKind::Select => "select",
PromptKind::MultiSelect => "multi-select",
PromptKind::Date => "date",
}
}
fn reference_kind_str(kind: ReferenceKind) -> &'static str {
match kind {
ReferenceKind::Label => "label",
ReferenceKind::Assignee => "assignee",
ReferenceKind::File => "file",
ReferenceKind::Cycle => "cycle",
ReferenceKind::Milestone => "milestone",
ReferenceKind::Relationship => "relationship",
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct CreateFields<'a> {
pub title: Option<&'a str>,
pub body: Option<&'a str>,
pub status: Option<&'a str>,
pub priority: Option<&'a str>,
pub assignee: Option<&'a str>,
pub labels: Option<&'a str>,
pub files: Option<&'a str>,
pub estimate: Option<&'a str>,
pub cycles: Option<&'a str>,
pub milestones: Option<&'a str>,
pub reviewer: Option<&'a str>,
pub start_date: Option<&'a str>,
pub due_date: Option<&'a str>,
pub parent: Option<&'a str>,
pub related_issues: Option<&'a str>,
pub type_: Option<&'a str>,
}
impl CreateFields<'_> {
#[must_use]
pub fn to_input(self) -> TemplateInput {
TemplateInput {
title: self.title.map(str::to_string),
body: self.body.map(str::to_string),
status: self.status.map(str::to_string),
priority: self.priority.map(str::to_string),
assignee: self.assignee.map(str::to_string),
labels: self.labels.map(str::to_string),
files: self.files.map(str::to_string),
estimate: self.estimate.map(str::to_string),
cycles: self.cycles.map(str::to_string),
milestones: self.milestones.map(str::to_string),
reviewer: self.reviewer.map(str::to_string),
start_date: self.start_date.map(str::to_string),
due_date: self.due_date.map(str::to_string),
parent: self.parent.map(str::to_string),
related_issues: self.related_issues.map(str::to_string),
type_: self.type_.map(str::to_string),
}
}
}
pub fn create_from_template(
config: &Config,
store: &Store,
template_id: &str,
scope: Option<&str>,
fields: &CreateFields<'_>,
) -> Result<()> {
let selection = selection_for(template_id, scope)?;
let resolution = resolve(config, &selection, &fields.to_input());
if !resolution.is_ok() {
return Err(resolution_error(&resolution));
}
let template_id = resolution.template_id().to_string();
let scope = resolution.scope().to_string();
let request = resolution
.into_request()
.expect("a successful resolution always carries a request");
eprintln!("using template `{template_id}` at scope `{scope}`");
let issue = apply_request(config, store, &request)?;
println!("Created {}", issue.filename);
Ok(())
}
pub fn selection_for(template_id: &str, scope: Option<&str>) -> Result<TemplateSelection> {
let scope = match scope {
Some(raw) => parse_scope(raw)?,
None => TemplateScope::repository(),
};
Ok(TemplateSelection::new(template_id, scope))
}
pub fn resolution_error(resolution: &TemplateResolution) -> TixError {
let mut message = format!(
"template `{}` at scope `{}` did not resolve:",
resolution.template_id(),
resolution.scope()
);
for diagnostic in resolution.diagnostics() {
message.push_str(&format!(
"\n - {}: {}: {}",
diagnostic.kind(),
diagnostic.field(),
diagnostic.message()
));
}
TixError::InvalidInput(message)
}
pub fn apply_request(
config: &Config,
store: &Store,
request: &CreateRequest,
) -> Result<crate::model::Issue> {
create::create(
config,
store,
&request.title,
request.body.as_deref(),
request.status.clone(),
request.priority.clone(),
request.assignee.as_deref(),
request.labels.as_deref(),
request.files.as_deref(),
request.estimate.as_deref(),
request.cycles.as_deref(),
request.milestones.as_deref(),
request.reviewer.as_deref(),
request.start_date.as_deref(),
request.due_date.as_deref(),
request.parent.as_deref(),
request.related_issues.as_deref(),
request.type_.as_deref(),
)
}