Skip to main content

kasl/commands/
template.rs

1//! Task template management command.
2//!
3//! Provides comprehensive template management functionality for kasl, enabling users to create, edit, delete, and search reusable task templates.
4//!
5//! ## Usage
6//!
7//! ```bash
8//! # List all templates
9//! kasl template list
10//!
11//! # Create new template
12//! kasl template add --name "bug-fix"
13//!
14//! # Search templates
15//! kasl template search "development"
16//!
17//! # Delete template
18//! kasl template remove "old-template"
19//! ```
20
21use crate::{
22    db::templates::{TaskTemplate, Templates},
23    libs::{
24        messages::Message,
25        pick,
26        prompt::{ensure_interactive, is_interactive},
27        view::View,
28    },
29    msg_error, msg_info, msg_print, msg_success,
30};
31use anyhow::Result;
32use clap::{Args, Subcommand};
33use dialoguer::{Confirm, Input, Select, theme::ColorfulTheme};
34
35/// Command-line arguments for template management operations.
36#[derive(Debug, Args)]
37pub struct TemplateArgs {
38    #[command(subcommand)]
39    command: Option<TemplateCommand>,
40}
41
42/// Available template management operations.
43#[derive(Debug, Subcommand)]
44enum TemplateCommand {
45    /// Add a new task template
46    ///
47    /// Creates a new reusable template with specified or interactive values.
48    /// Templates provide default values for task creation, streamlining
49    /// workflows for frequently created task types.
50    Add {
51        /// Unique name identifier for the template
52        ///
53        /// Must be unique across all templates and should be descriptive
54        /// enough to easily identify the template's purpose. Used for
55        /// referencing the template in task creation commands.
56        #[arg(short, long)]
57        name: Option<String>,
58
59        /// Task name the template fills in
60        ///
61        /// Supplying it - along with `--name` - is what makes template
62        /// creation scriptable; the remaining fields fall back to an empty
63        /// comment and 100% completeness.
64        #[arg(short = 't', long)]
65        task_name: Option<String>,
66
67        /// Comment the template fills in
68        #[arg(long)]
69        comment: Option<String>,
70
71        /// Default completion percentage (0-100)
72        #[arg(short, long)]
73        completeness: Option<i32>,
74    },
75
76    /// List all available templates
77    ///
78    /// Displays a formatted table of all existing templates with their
79    /// names, task names, comments, and default completion values.
80    /// Useful for reviewing available templates and their configurations.
81    List,
82
83    /// Show a single template's contents
84    ///
85    /// Displays the task name, comment and default completeness stored in
86    /// the template, so its effect on task creation is visible before use.
87    Show {
88        /// Name of the template to show
89        name: Option<String>,
90    },
91
92    /// Edit an existing template
93    ///
94    /// Modifies an existing template's properties including task name,
95    /// comment, and completion status. Provides interactive interface
96    /// for template selection if name is not specified.
97    Edit {
98        /// Name of the template to edit
99        ///
100        /// If not provided, an interactive selection interface will be
101        /// presented with all available templates.
102        name: Option<String>,
103    },
104
105    /// Remove a template
106    ///
107    /// Permanently removes a template from the system. Includes confirmation
108    /// prompt to prevent accidental removal. Removing a template does not
109    /// affect tasks that were previously created from it.
110    Remove {
111        /// Name of the template to remove
112        ///
113        /// If not provided, an interactive selection interface will be
114        /// presented with all available templates.
115        name: Option<String>,
116
117        /// Remove without asking for confirmation
118        #[arg(long, short = 'y')]
119        yes: bool,
120    },
121
122    /// Search templates by name or content
123    ///
124    /// Performs a text search across template names and task names,
125    /// returning all matching templates. Useful for finding templates
126    /// in large template libraries.
127    Search {
128        /// Search query string
129        ///
130        /// Searches both template names and task names for matches.
131        /// Case-insensitive partial matching is supported.
132        query: String,
133    },
134}
135
136/// Executes template management operations based on the specified subcommand.
137///
138/// # Examples
139///
140/// ```bash
141/// # Create a new template interactively
142/// kasl template add
143///
144/// # Create template with specific name
145/// kasl template add --name daily-standup
146///
147/// # List all templates
148/// kasl template list
149///
150/// # Edit a template
151/// kasl template edit daily-standup
152///
153/// # Search for templates
154/// kasl template search meeting
155///
156/// # Interactive mode
157/// kasl template
158/// ```
159pub fn cmd(args: TemplateArgs) -> Result<()> {
160    match args.command {
161        Some(TemplateCommand::Add {
162            name,
163            task_name,
164            comment,
165            completeness,
166        }) => handle_create(name, task_name, comment, completeness),
167        Some(TemplateCommand::List) => handle_list(),
168        Some(TemplateCommand::Show { name }) => handle_show(name),
169        Some(TemplateCommand::Edit { name }) => handle_edit(name),
170        Some(TemplateCommand::Remove { name, yes }) => handle_delete(name, yes),
171        Some(TemplateCommand::Search { query }) => handle_search(query),
172        None => {
173            ensure_interactive("no subcommand given; run `kasl template list` or see `kasl template --help`")?;
174            handle_interactive()
175        }
176    }
177}
178
179/// Handles template creation with validation and uniqueness checking.
180///
181/// Every field has a flag, so a template can be created from a script; what is
182/// left out is asked for in a terminal and defaulted outside one. The name is
183/// the exception - without it there is nothing to ask for outside a terminal,
184/// so the command refuses instead of prompting into a void.
185fn handle_create(name: Option<String>, task_name: Option<String>, comment: Option<String>, completeness: Option<i32>) -> Result<()> {
186    let mut templates_db = Templates::new()?;
187
188    if name.is_none() {
189        ensure_interactive("template name is required; pass --name outside an interactive terminal")?;
190    }
191
192    let name = match name {
193        Some(n) => n,
194        None => Input::with_theme(&ColorfulTheme::default())
195            .with_prompt(Message::PromptTemplateName.to_string())
196            .interact_text()?,
197    };
198
199    // Validate template name uniqueness
200    if templates_db.exists(&name)? {
201        msg_error!(Message::TemplateAlreadyExists(name));
202        return Ok(());
203    }
204
205    let interactive = is_interactive();
206
207    let task_name = match task_name {
208        Some(t) => t,
209        // A template whose task name is the template name is still usable, and
210        // beats refusing the whole command over a field that has a sane guess.
211        None if !interactive => name.clone(),
212        None => Input::with_theme(&ColorfulTheme::default())
213            .with_prompt(Message::PromptTemplateTaskName.to_string())
214            .interact_text()?,
215    };
216
217    let comment = match comment {
218        Some(c) => c,
219        None if !interactive => String::new(),
220        None => Input::with_theme(&ColorfulTheme::default())
221            .with_prompt(Message::PromptTemplateComment.to_string())
222            .allow_empty(true)
223            .interact_text()?,
224    };
225
226    let completeness_range_msg = Message::TaskCompletenessRange.to_string();
227    let completeness = match completeness {
228        Some(c) => {
229            if !(0..=100).contains(&c) {
230                msg_error!(Message::TaskCompletenessRange);
231                return Ok(());
232            }
233            c
234        }
235        None if !interactive => 100,
236        None => Input::with_theme(&ColorfulTheme::default())
237            .with_prompt(Message::PromptTemplateCompleteness.to_string())
238            .default(100)
239            .validate_with(|input: &i32| -> Result<(), &str> {
240                if *input >= 0 && *input <= 100 { Ok(()) } else { Err(&completeness_range_msg) }
241            })
242            .interact_text()?,
243    };
244
245    // Create and save the template
246    let template = TaskTemplate::new(name.clone(), task_name, comment, completeness);
247    templates_db.create(&template)?;
248
249    msg_success!(Message::TemplateCreated(name));
250    Ok(())
251}
252
253/// Displays all available templates in a formatted table.
254fn handle_list() -> Result<()> {
255    let mut templates_db = Templates::new()?;
256    let templates = templates_db.get_all()?;
257
258    if templates.is_empty() {
259        msg_info!(Message::NoTemplatesFound);
260        return Ok(());
261    }
262
263    msg_print!(Message::TemplateListHeader, true);
264    View::templates(&templates)?;
265    Ok(())
266}
267
268/// Displays a single template's stored values.
269///
270/// Reuses the same table rendering as `list` so a template reads identically
271/// whether shown alone or among others. Without a name, the template is picked
272/// interactively.
273fn handle_show(name: Option<String>) -> Result<()> {
274    let mut templates_db = Templates::new()?;
275
276    let name = match name {
277        Some(n) => n,
278        None => {
279            ensure_interactive("template name is required outside an interactive terminal")?;
280
281            let templates = templates_db.get_all()?;
282            if templates.is_empty() {
283                msg_info!(Message::NoTemplatesFound);
284                return Ok(());
285            }
286
287            pick::template(&templates, &Message::SelectTemplate.to_string())?
288        }
289    };
290
291    match templates_db.get(&name)? {
292        Some(template) => {
293            msg_print!(Message::TemplateListHeader, true);
294            View::templates(&[template])?;
295        }
296        None => msg_error!(Message::TemplateNotFound(name)),
297    }
298
299    Ok(())
300}
301
302/// Handles template editing with interactive or direct name specification.
303fn handle_edit(name: Option<String>) -> Result<()> {
304    let mut templates_db = Templates::new()?;
305
306    // Get template name (direct or interactive selection)
307    let name = match name {
308        Some(n) => n,
309        None => {
310            let templates = templates_db.get_all()?;
311            if templates.is_empty() {
312                msg_info!(Message::NoTemplatesFound);
313                return Ok(());
314            }
315
316            pick::template(&templates, &Message::SelectTemplateToEdit.to_string())?
317        }
318    };
319
320    // Fetch the template to edit
321    let template = match templates_db.get(&name)? {
322        Some(t) => t,
323        None => {
324            msg_error!(Message::TemplateNotFound(name));
325            return Ok(());
326        }
327    };
328
329    msg_print!(Message::EditingTemplate(template.name.clone()), true);
330
331    // Interactive editing with current values as defaults
332    let task_name = Input::with_theme(&ColorfulTheme::default())
333        .with_prompt(Message::PromptTemplateTaskName.to_string())
334        .default(template.task_name.clone())
335        .interact_text()?;
336
337    let comment = Input::with_theme(&ColorfulTheme::default())
338        .with_prompt(Message::PromptTemplateComment.to_string())
339        .default(template.comment.clone())
340        .allow_empty(true)
341        .interact_text()?;
342
343    let completeness_range_msg = Message::TaskCompletenessRange.to_string();
344    let completeness = Input::with_theme(&ColorfulTheme::default())
345        .with_prompt(Message::PromptTemplateCompleteness.to_string())
346        .default(template.completeness)
347        .validate_with(|input: &i32| -> Result<(), &str> {
348            if *input >= 0 && *input <= 100 { Ok(()) } else { Err(&completeness_range_msg) }
349        })
350        .interact_text()?;
351
352    // Update the template
353    let updated_template = TaskTemplate::new(name.clone(), task_name, comment, completeness);
354    templates_db.update(&updated_template)?;
355
356    msg_success!(Message::TemplateUpdated(name));
357    Ok(())
358}
359
360/// Handles safe template deletion with confirmation.
361fn handle_delete(name: Option<String>, assume_yes: bool) -> Result<()> {
362    let mut templates_db = Templates::new()?;
363
364    // Get template name (direct or interactive selection)
365    let name = match name {
366        Some(n) => n,
367        None => {
368            ensure_interactive("template name is required outside an interactive terminal")?;
369
370            let templates = templates_db.get_all()?;
371            if templates.is_empty() {
372                msg_info!(Message::NoTemplatesFound);
373                return Ok(());
374            }
375
376            pick::template(&templates, &Message::SelectTemplateToDelete.to_string())?
377        }
378    };
379
380    if !assume_yes {
381        // Never block on a prompt when there is no one to answer it.
382        ensure_interactive(&format!("refusing to remove template '{}' without --yes outside an interactive terminal", name))?;
383
384        let confirmed = Confirm::with_theme(&ColorfulTheme::default())
385            .with_prompt(Message::ConfirmDeleteTemplate(name.clone()).to_string())
386            .default(false)
387            .interact()?;
388
389        if !confirmed {
390            msg_info!(Message::OperationCancelled);
391            return Ok(());
392        }
393    }
394
395    templates_db.delete(&name)?;
396    msg_success!(Message::TemplateDeleted(name));
397
398    Ok(())
399}
400
401/// Handles template search functionality.
402fn handle_search(query: String) -> Result<()> {
403    let mut templates_db = Templates::new()?;
404    let templates = templates_db.search(&query)?;
405
406    if templates.is_empty() {
407        msg_info!(Message::NoTemplatesMatchingQuery(query));
408        return Ok(());
409    }
410
411    msg_print!(Message::TemplateSearchResults(query), true);
412    View::templates(&templates)?;
413    Ok(())
414}
415
416/// Handles interactive template management when no subcommand is provided.
417fn handle_interactive() -> Result<()> {
418    let options = vec!["Add new template", "List templates", "Show template", "Edit template", "Remove template"];
419
420    let selection = Select::with_theme(&ColorfulTheme::default())
421        .with_prompt(Message::SelectTemplateAction.to_string())
422        .items(&options)
423        .interact()?;
424
425    match selection {
426        0 => handle_create(None, None, None, None),
427        1 => handle_list(),
428        2 => handle_show(None),
429        3 => handle_edit(None),
430        4 => handle_delete(None, false),
431        _ => Ok(()),
432    }
433}