1use std::fmt::Write as _;
33use std::io::IsTerminal;
34use std::path::{Path, PathBuf};
35
36use crate::cli::TemplateAction;
37use crate::commands::create;
38use crate::commands::parse_csv_list;
39use crate::config::Config;
40use crate::error::{Result, TixError};
41use crate::storage::Store;
42use crate::templates::registry::{
43 CREATE_FIELDS, PromptKind, ReferenceKind, TemplateDefaults, TemplatePrompt, TemplateReference,
44 TemplateScope,
45};
46use crate::templates::resolver::{
47 CreateRequest, TemplateInput, TemplateResolution, TemplateSelection, resolve,
48};
49use crate::templates::{
50 DeleteConfirmed, TemplateDefinition, TemplateDetail, TemplateService, TemplateWriteError,
51};
52
53pub fn run(cwd: &Path, action: &TemplateAction) -> Result<()> {
66 let (mut config, path) = load_management_config(cwd)?;
67
68 match action {
69 TemplateAction::List => {
70 let service = TemplateService::new(&mut config, &path);
71 print!("{}", render_list(&service.list()));
72 Ok(())
73 }
74 TemplateAction::Show { reference } => {
75 let service = TemplateService::new(&mut config, &path);
76 print!("{}", render_detail(&service.show(reference)?));
77 Ok(())
78 }
79 TemplateAction::Create {
80 id,
81 name,
82 scope,
83 defaults,
84 prompts,
85 references,
86 } => {
87 let definition = definition_for_create(
88 name.as_deref(),
89 scope.as_deref(),
90 defaults,
91 prompts,
92 references,
93 )?;
94 let mut service = TemplateService::new(&mut config, &path);
95 let outcome = service
96 .create(id, definition)
97 .map_err(TemplateWriteError::into_inner)?;
98 println!("created template `{}`", outcome.value());
99 Ok(())
100 }
101 TemplateAction::Update {
102 reference,
103 name,
104 scope,
105 defaults,
106 prompts,
107 references,
108 } => {
109 let definition = definition_for_update(
110 name.as_deref(),
111 scope.as_deref(),
112 defaults,
113 prompts,
114 references,
115 )?;
116 let mut service = TemplateService::new(&mut config, &path);
117 let outcome = service
118 .update(reference, definition)
119 .map_err(TemplateWriteError::into_inner)?;
120 if outcome.is_noop() {
121 println!("unchanged template `{}`", outcome.value());
122 } else {
123 println!("updated template `{}`", outcome.value());
124 }
125 Ok(())
126 }
127 TemplateAction::Duplicate {
128 reference,
129 id,
130 name,
131 } => {
132 let mut service = TemplateService::new(&mut config, &path);
133 let outcome = service
134 .duplicate(reference, id.as_deref(), name.as_deref())
135 .map_err(TemplateWriteError::into_inner)?;
136 println!(
137 "duplicated template `{reference}` as `{}`",
138 outcome.value().id()
139 );
140 Ok(())
141 }
142 TemplateAction::Delete { reference, yes } => {
143 confirm_delete(reference, *yes)?;
144 let mut service = TemplateService::new(&mut config, &path);
145 let outcome = service
146 .delete(reference, DeleteConfirmed::confirmed())
147 .map_err(TemplateWriteError::into_inner)?;
148 println!("deleted template `{}`", outcome.value().id());
149 Ok(())
150 }
151 }
152}
153
154fn load_management_config(cwd: &Path) -> Result<(Config, PathBuf)> {
160 let root = Config::project_root(cwd)
161 .ok_or_else(|| TixError::Config("no tix.toml found — run \"tix init\" first".into()))?;
162 let config = Config::load(&root)?;
163 Ok((config, root.join("tix.toml")))
164}
165
166fn confirm_delete(reference: &str, yes: bool) -> Result<()> {
169 let mut confirmed = yes || !std::io::stdin().is_terminal();
170 if !confirmed {
171 eprint!("Delete template '{reference}'? [y/N] ");
172 let mut line = String::new();
173 std::io::stdin()
174 .read_line(&mut line)
175 .map_err(TixError::Io)?;
176 confirmed = line.trim().eq_ignore_ascii_case("y");
177 }
178 if !confirmed {
179 return Err(TixError::InvalidInput(format!(
180 "deletion of template `{reference}` was not confirmed"
181 )));
182 }
183 Ok(())
184}
185
186fn definition_for_create(
192 name: Option<&str>,
193 scope: Option<&str>,
194 defaults: &[String],
195 prompts: &[String],
196 references: &[String],
197) -> Result<TemplateDefinition> {
198 let scope = match scope {
201 Some(raw) => parse_scope(raw)?,
202 None => TemplateScope::repository(),
203 };
204 let mut definition = match name {
205 Some(name) => TemplateDefinition::new(name, scope),
206 None => TemplateDefinition::unnamed(scope),
207 };
208 if !defaults.is_empty() {
209 definition = definition.with_defaults(build_defaults(defaults)?);
210 }
211 if !prompts.is_empty() {
212 definition = definition.with_prompts(build_prompts(prompts)?);
213 }
214 if !references.is_empty() {
215 definition = definition.with_references(build_references(references)?);
216 }
217 Ok(definition)
218}
219
220fn definition_for_update(
227 name: Option<&str>,
228 scope: Option<&str>,
229 defaults: &[String],
230 prompts: &[String],
231 references: &[String],
232) -> Result<TemplateDefinition> {
233 let mut definition = match (name, scope) {
234 (Some(name), Some(raw)) => TemplateDefinition::new(name, parse_scope(raw)?),
235 (Some(name), None) => TemplateDefinition::rename(name),
236 (None, Some(raw)) => TemplateDefinition::unnamed(parse_scope(raw)?),
237 (None, None) => TemplateDefinition::default(),
238 };
239 if !defaults.is_empty() {
244 definition = definition.with_defaults(build_defaults(defaults)?);
245 }
246 if !prompts.is_empty() {
247 definition = definition.with_prompts(build_prompts(prompts)?);
248 }
249 if !references.is_empty() {
250 definition = definition.with_references(build_references(references)?);
251 }
252 Ok(definition)
253}
254
255fn parse_scope(raw: &str) -> Result<TemplateScope> {
257 raw.parse::<TemplateScope>().map_err(TixError::InvalidInput)
258}
259
260fn build_defaults(specs: &[String]) -> Result<TemplateDefaults> {
266 let mut defaults = TemplateDefaults::default();
267 for spec in specs {
268 let Some((field, value)) = spec.split_once('=') else {
269 return Err(TixError::InvalidInput(format!(
270 "--default `{spec}` must be `FIELD=VALUE`"
271 )));
272 };
273 let field = field.trim();
274 let value = value.trim().to_string();
275 match field {
276 "title" => defaults.title = Some(value),
277 "body" => defaults.body = Some(value),
278 "status" => defaults.status = Some(value),
279 "priority" => defaults.priority = Some(value),
280 "assignee" => defaults.assignee = Some(value),
281 "labels" => defaults.labels = parse_csv_list(&value),
282 "files" => defaults.files = parse_csv_list(&value),
283 "estimate" => defaults.estimate = Some(value),
284 "cycles" => defaults.cycles = parse_csv_list(&value),
285 "milestones" => defaults.milestones = parse_csv_list(&value),
286 "reviewer" => defaults.reviewer = Some(value),
287 "start_date" => defaults.start_date = Some(value),
288 "due_date" => defaults.due_date = Some(value),
289 "parent" => defaults.parent = Some(value),
290 "related_issues" => defaults.related_issues = parse_csv_list(&value),
291 "type" => defaults.type_ = Some(value),
292 other => {
293 return Err(TixError::InvalidInput(format!(
294 "`{other}` is not a create field; expected one of {}",
295 CREATE_FIELDS.join(", ")
296 )));
297 }
298 }
299 }
300 Ok(defaults)
301}
302
303fn build_prompts(specs: &[String]) -> Result<Vec<TemplatePrompt>> {
305 specs.iter().map(|spec| parse_prompt(spec)).collect()
306}
307
308fn parse_prompt(raw: &str) -> Result<TemplatePrompt> {
309 let mut key: Option<String> = None;
310 let mut label: Option<String> = None;
311 let mut kind = PromptKind::Text;
312 let mut required = false;
313 let mut default: Option<String> = None;
314 let mut options_ref: Option<String> = None;
315
316 for (name, value) in split_attributes(raw, "--prompt")? {
317 match name {
318 "key" => key = Some(value.to_string()),
319 "kind" => kind = parse_prompt_kind(value)?,
320 "required" => required = parse_bool(value, "--prompt required")?,
321 "default" => default = Some(value.to_string()),
322 "label" => label = Some(value.to_string()),
323 "options_ref" => options_ref = Some(value.to_string()),
324 other => {
325 return Err(TixError::InvalidInput(format!(
326 "unknown --prompt attribute `{other}`; expected key, kind, required, \
327 default, label or options_ref"
328 )));
329 }
330 }
331 }
332
333 let key = key.ok_or_else(|| {
334 TixError::InvalidInput("--prompt requires `key=<create field>`".to_string())
335 })?;
336 Ok(TemplatePrompt {
337 key,
338 label,
339 kind,
340 required,
341 default,
342 options_ref,
343 })
344}
345
346fn build_references(specs: &[String]) -> Result<Vec<TemplateReference>> {
348 specs.iter().map(|spec| parse_reference(spec)).collect()
349}
350
351fn parse_reference(raw: &str) -> Result<TemplateReference> {
352 let mut kind: Option<ReferenceKind> = None;
353 let mut value: Option<String> = None;
354 let mut required = false;
355
356 for (name, raw_value) in split_attributes(raw, "--reference")? {
357 match name {
358 "kind" => kind = Some(parse_reference_kind(raw_value)?),
359 "value" => value = Some(raw_value.to_string()),
360 "required" => required = parse_bool(raw_value, "--reference required")?,
361 other => {
362 return Err(TixError::InvalidInput(format!(
363 "unknown --reference attribute `{other}`; expected kind, value or required"
364 )));
365 }
366 }
367 }
368
369 let kind = kind
370 .ok_or_else(|| TixError::InvalidInput("--reference requires `kind=<kind>`".to_string()))?;
371 let value = value.ok_or_else(|| {
372 TixError::InvalidInput("--reference requires `value=<value>`".to_string())
373 })?;
374 Ok(TemplateReference {
375 kind,
376 value,
377 required,
378 })
379}
380
381fn split_attributes<'a>(raw: &'a str, flag: &str) -> Result<Vec<(&'a str, &'a str)>> {
387 let mut out = Vec::new();
388 for token in raw.split(',') {
389 let token = token.trim();
390 if token.is_empty() {
391 continue;
392 }
393 let Some((name, value)) = token.split_once('=') else {
394 return Err(TixError::InvalidInput(format!(
395 "{flag} attribute `{token}` must be `key=value`"
396 )));
397 };
398 out.push((name.trim(), value.trim()));
399 }
400 Ok(out)
401}
402
403fn parse_bool(raw: &str, what: &str) -> Result<bool> {
404 match raw {
405 "true" => Ok(true),
406 "false" => Ok(false),
407 other => Err(TixError::InvalidInput(format!(
408 "{what} must be `true` or `false`, got `{other}`"
409 ))),
410 }
411}
412
413fn parse_prompt_kind(raw: &str) -> Result<PromptKind> {
416 match raw {
417 "text" => Ok(PromptKind::Text),
418 "select" => Ok(PromptKind::Select),
419 "multi-select" => Ok(PromptKind::MultiSelect),
420 "date" => Ok(PromptKind::Date),
421 other => Err(TixError::InvalidInput(format!(
422 "unknown prompt kind `{other}`; expected text, select, multi-select or date"
423 ))),
424 }
425}
426
427fn parse_reference_kind(raw: &str) -> Result<ReferenceKind> {
428 match raw {
429 "label" => Ok(ReferenceKind::Label),
430 "assignee" => Ok(ReferenceKind::Assignee),
431 "file" => Ok(ReferenceKind::File),
432 "cycle" => Ok(ReferenceKind::Cycle),
433 "milestone" => Ok(ReferenceKind::Milestone),
434 "relationship" => Ok(ReferenceKind::Relationship),
435 other => Err(TixError::InvalidInput(format!(
436 "unknown reference kind `{other}`; expected label, assignee, file, cycle, \
437 milestone or relationship"
438 ))),
439 }
440}
441
442pub fn render_list(details: &[TemplateDetail]) -> String {
452 let mut out = String::new();
453 for detail in details {
454 let _ = writeln!(
455 out,
456 "{}\t{}\t{}",
457 detail.id(),
458 detail.display_name(),
459 detail.scope()
460 );
461 }
462 out
463}
464
465pub fn render_detail(detail: &TemplateDetail) -> String {
474 let mut out = String::new();
475 let _ = writeln!(out, "id: {}", detail.id());
476 let _ = writeln!(out, "name: {}", detail.display_name());
477 let _ = writeln!(out, "scope: {}", detail.scope());
478 out.push_str(&render_defaults(detail.defaults()));
479 for (index, prompt) in detail.prompts().iter().enumerate() {
480 let _ = writeln!(out, "prompt[{index}].key: {}", prompt.key);
481 if let Some(label) = &prompt.label {
482 let _ = writeln!(out, "prompt[{index}].label: {label}");
483 }
484 let _ = writeln!(
485 out,
486 "prompt[{index}].kind: {}",
487 prompt_kind_str(prompt.kind)
488 );
489 let _ = writeln!(out, "prompt[{index}].required: {}", prompt.required);
490 if let Some(default) = &prompt.default {
491 let _ = writeln!(out, "prompt[{index}].default: {default}");
492 }
493 if let Some(options_ref) = &prompt.options_ref {
494 let _ = writeln!(out, "prompt[{index}].options_ref: {options_ref}");
495 }
496 }
497 for (index, reference) in detail.references().iter().enumerate() {
498 let _ = writeln!(
499 out,
500 "reference[{index}].kind: {}",
501 reference_kind_str(reference.kind)
502 );
503 let _ = writeln!(out, "reference[{index}].value: {}", reference.value);
504 let _ = writeln!(out, "reference[{index}].required: {}", reference.required);
505 }
506 for (key, value) in detail.extensions().iter() {
509 let _ = writeln!(out, "unknown.{key}: {value}");
510 }
511 out
512}
513
514fn render_defaults(defaults: &TemplateDefaults) -> String {
516 let mut out = String::new();
517 for (field, value) in [
518 ("title", defaults.title.as_deref()),
519 ("body", defaults.body.as_deref()),
520 ("status", defaults.status.as_deref()),
521 ("priority", defaults.priority.as_deref()),
522 ("assignee", defaults.assignee.as_deref()),
523 ("estimate", defaults.estimate.as_deref()),
524 ("reviewer", defaults.reviewer.as_deref()),
525 ("start_date", defaults.start_date.as_deref()),
526 ("due_date", defaults.due_date.as_deref()),
527 ("parent", defaults.parent.as_deref()),
528 ("type", defaults.type_.as_deref()),
529 ] {
530 if let Some(value) = value {
531 let _ = writeln!(out, "defaults.{field}: {value}");
532 }
533 }
534 for (field, values) in [
535 ("labels", &defaults.labels),
536 ("files", &defaults.files),
537 ("cycles", &defaults.cycles),
538 ("milestones", &defaults.milestones),
539 ("related_issues", &defaults.related_issues),
540 ] {
541 if !values.is_empty() {
542 let _ = writeln!(out, "defaults.{field}: {}", values.join(", "));
543 }
544 }
545 out
546}
547
548fn prompt_kind_str(kind: PromptKind) -> &'static str {
549 match kind {
550 PromptKind::Text => "text",
551 PromptKind::Select => "select",
552 PromptKind::MultiSelect => "multi-select",
553 PromptKind::Date => "date",
554 }
555}
556
557fn reference_kind_str(kind: ReferenceKind) -> &'static str {
558 match kind {
559 ReferenceKind::Label => "label",
560 ReferenceKind::Assignee => "assignee",
561 ReferenceKind::File => "file",
562 ReferenceKind::Cycle => "cycle",
563 ReferenceKind::Milestone => "milestone",
564 ReferenceKind::Relationship => "relationship",
565 }
566}
567
568#[derive(Debug, Default, Clone, Copy)]
578pub struct CreateFields<'a> {
579 pub title: Option<&'a str>,
580 pub body: Option<&'a str>,
581 pub status: Option<&'a str>,
582 pub priority: Option<&'a str>,
583 pub assignee: Option<&'a str>,
584 pub labels: Option<&'a str>,
585 pub files: Option<&'a str>,
586 pub estimate: Option<&'a str>,
587 pub cycles: Option<&'a str>,
588 pub milestones: Option<&'a str>,
589 pub reviewer: Option<&'a str>,
590 pub start_date: Option<&'a str>,
591 pub due_date: Option<&'a str>,
592 pub parent: Option<&'a str>,
593 pub related_issues: Option<&'a str>,
594 pub type_: Option<&'a str>,
595}
596
597impl CreateFields<'_> {
598 #[must_use]
602 pub fn to_input(self) -> TemplateInput {
603 TemplateInput {
604 title: self.title.map(str::to_string),
605 body: self.body.map(str::to_string),
606 status: self.status.map(str::to_string),
607 priority: self.priority.map(str::to_string),
608 assignee: self.assignee.map(str::to_string),
609 labels: self.labels.map(str::to_string),
610 files: self.files.map(str::to_string),
611 estimate: self.estimate.map(str::to_string),
612 cycles: self.cycles.map(str::to_string),
613 milestones: self.milestones.map(str::to_string),
614 reviewer: self.reviewer.map(str::to_string),
615 start_date: self.start_date.map(str::to_string),
616 due_date: self.due_date.map(str::to_string),
617 parent: self.parent.map(str::to_string),
618 related_issues: self.related_issues.map(str::to_string),
619 type_: self.type_.map(str::to_string),
620 }
621 }
622}
623
624pub fn create_from_template(
636 config: &Config,
637 store: &Store,
638 template_id: &str,
639 scope: Option<&str>,
640 fields: &CreateFields<'_>,
641) -> Result<()> {
642 let selection = selection_for(template_id, scope)?;
643 let resolution = resolve(config, &selection, &fields.to_input());
644
645 if !resolution.is_ok() {
646 return Err(resolution_error(&resolution));
647 }
648
649 let template_id = resolution.template_id().to_string();
650 let scope = resolution.scope().to_string();
651 let request = resolution
652 .into_request()
653 .expect("a successful resolution always carries a request");
654 eprintln!("using template `{template_id}` at scope `{scope}`");
655 let issue = apply_request(config, store, &request)?;
656 println!("Created {}", issue.filename);
657 Ok(())
658}
659
660pub fn selection_for(template_id: &str, scope: Option<&str>) -> Result<TemplateSelection> {
667 let scope = match scope {
668 Some(raw) => parse_scope(raw)?,
669 None => TemplateScope::repository(),
670 };
671 Ok(TemplateSelection::new(template_id, scope))
672}
673
674pub fn resolution_error(resolution: &TemplateResolution) -> TixError {
680 let mut message = format!(
681 "template `{}` at scope `{}` did not resolve:",
682 resolution.template_id(),
683 resolution.scope()
684 );
685 for diagnostic in resolution.diagnostics() {
686 message.push_str(&format!(
687 "\n - {}: {}: {}",
688 diagnostic.kind(),
689 diagnostic.field(),
690 diagnostic.message()
691 ));
692 }
693 TixError::InvalidInput(message)
694}
695
696pub fn apply_request(
702 config: &Config,
703 store: &Store,
704 request: &CreateRequest,
705) -> Result<crate::model::Issue> {
706 create::create(
707 config,
708 store,
709 &request.title,
710 request.body.as_deref(),
711 request.status.clone(),
712 request.priority.clone(),
713 request.assignee.as_deref(),
714 request.labels.as_deref(),
715 request.files.as_deref(),
716 request.estimate.as_deref(),
717 request.cycles.as_deref(),
718 request.milestones.as_deref(),
719 request.reviewer.as_deref(),
720 request.start_date.as_deref(),
721 request.due_date.as_deref(),
722 request.parent.as_deref(),
723 request.related_issues.as_deref(),
724 request.type_.as_deref(),
725 )
726}