use crate::{
api::{gitlab::GitLab, jira::Jira},
db::tasks::Tasks,
db::templates::Templates,
libs::{
config::Config,
messages::Message,
prompt::{ensure_interactive, is_interactive},
stdin_drain::drain_available_stdin_lines,
task::{Task, TaskFilter, collapse_whitespace, is_ignored_name, normalize_task_name},
view::View,
},
msg_error, msg_info, msg_print, msg_success, msg_warning,
};
use anyhow::Result;
use chrono::Local;
use clap::{Args, Subcommand};
use dialoguer::{Confirm, Input, MultiSelect, Select, theme::ColorfulTheme};
use indicatif::{ProgressBar, ProgressStyle};
use std::collections::{HashMap, HashSet};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum TaskSource {
Incomplete,
Gitlab,
Jira,
}
#[derive(Debug, Clone)]
struct DiscoveryItem {
source: TaskSource,
task: Task,
short_sha: Option<String>,
}
impl TaskSource {
fn priority(self) -> u8 {
match self {
TaskSource::Incomplete => 0,
TaskSource::Jira => 1,
TaskSource::Gitlab => 2,
}
}
}
fn format_discovery_item(item: &DiscoveryItem) -> String {
match item.source {
TaskSource::Incomplete => {
format!("↻ {} — {}%", item.task.name, item.task.completeness.unwrap_or(0))
}
TaskSource::Jira => format!("◉ {}", item.task.name),
TaskSource::Gitlab => match &item.short_sha {
Some(sha) => format!("● {} ({})", item.task.name, sha),
None => format!("● {}", item.task.name),
},
}
}
fn dedup_discovery_items(items: Vec<DiscoveryItem>) -> Vec<DiscoveryItem> {
let mut best: HashMap<String, DiscoveryItem> = HashMap::new();
for item in items {
let key = normalize_task_name(&item.task.name);
match best.get(&key) {
Some(existing) if existing.source.priority() <= item.source.priority() => {}
_ => {
best.insert(key, item);
}
}
}
let mut result: Vec<DiscoveryItem> = best.into_values().collect();
result.sort_by(|a, b| a.source.priority().cmp(&b.source.priority()).then_with(|| a.task.name.cmp(&b.task.name)));
result
}
#[derive(Debug, Args)]
pub struct TaskArgs {
#[command(subcommand)]
command: Option<TaskCommand>,
}
#[derive(Debug, Subcommand)]
enum TaskCommand {
#[command(about = "Add a task")]
Add(AddArgs),
#[command(about = "List tasks")]
List(ListArgs),
#[command(about = "Show tasks by id")]
Show {
#[arg(value_name = "ID", required = true, num_args = 1..)]
id: Vec<i32>,
},
#[command(about = "Edit a task by id, or several interactively")]
Edit {
#[arg(value_name = "ID")]
id: Option<i32>,
},
#[command(about = "Remove tasks by id, or all of today's")]
Remove(RemoveArgs),
#[command(about = "Find incomplete tasks and import from GitLab/Jira")]
Find,
}
#[derive(Debug, Args, Default)]
pub struct AddArgs {
#[arg(short, long)]
name: Option<String>,
#[arg(long)]
comment: Option<String>,
#[arg(short, long)]
completeness: Option<i32>,
#[arg(long)]
tags: Option<String>,
#[arg(long, short = 't')]
template: Option<String>,
#[arg(long, short = 'l')]
from_template: bool,
}
#[derive(Debug, Args)]
pub struct ListArgs {
#[arg(short, long)]
all: bool,
#[arg(long)]
tag: Option<String>,
}
#[derive(Debug, Args)]
pub struct RemoveArgs {
#[arg(value_name = "ID", num_args = 1..)]
id: Vec<i32>,
#[arg(long)]
today: bool,
#[arg(long, short = 'y')]
yes: bool,
}
pub async fn cmd(task_args: TaskArgs) -> Result<()> {
let date = Local::now();
match task_args.command {
Some(TaskCommand::Add(args)) => {
if let Some(template_name) = args.template {
return handle_create_from_template(template_name).await;
}
if args.from_template {
return handle_create_from_template_interactive().await;
}
handle_task_creation(args).await
}
Some(TaskCommand::List(args)) => {
let filter = if args.all {
TaskFilter::All
} else if let Some(tag) = args.tag {
TaskFilter::ByTag(tag)
} else {
TaskFilter::Date(date.date_naive())
};
show_tasks(filter)
}
Some(TaskCommand::Show { id }) => show_tasks(TaskFilter::ByIds(id)),
Some(TaskCommand::Edit { id }) => match id {
Some(id) => handle_edit_by_id(id).await,
None => handle_edit_interactive().await,
},
Some(TaskCommand::Remove(args)) => {
if args.today {
handle_delete_today(args.yes).await
} else if args.id.is_empty() {
msg_error!(Message::NoTaskIdsProvided);
Ok(())
} else {
handle_delete_by_ids(args.id, args.yes).await
}
}
Some(TaskCommand::Find) => handle_task_discovery(date).await,
None => handle_task_creation(AddArgs::default()).await,
}
}
fn show_tasks(filter: TaskFilter) -> Result<()> {
let tasks = Tasks::new()?.fetch(filter)?;
if tasks.is_empty() {
msg_error!(Message::TaskNotFound);
return Ok(());
}
View::tasks(&tasks)?;
Ok(())
}
async fn handle_task_discovery(date: chrono::DateTime<Local>) -> Result<()> {
ensure_interactive("`kasl task find` is interactive and needs a terminal")?;
let date_naive = date.date_naive();
let mut config = Config::read()?;
let gitlab_config = config.gitlab.clone();
let jira_config = config.jira.clone();
let ignore_names = config.effective_ignore_names();
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::with_template("{spinner:.cyan} {msg}")
.unwrap_or_else(|_| ProgressStyle::default_spinner())
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
);
spinner.enable_steady_tick(Duration::from_millis(80));
spinner.set_message(Message::TasksDiscoverySearchingIncomplete.to_string());
let incomplete_tasks = Tasks::new()?.fetch(TaskFilter::Incomplete)?;
let today_tasks = Tasks::new()?.fetch(TaskFilter::Date(date_naive))?;
let today_names: HashSet<String> = today_tasks.iter().map(|t| normalize_task_name(&t.name)).collect();
spinner.set_message(Message::TasksDiscoveryFetchingExternal.to_string());
let (commits_result, jira_result) = tokio::join!(
async {
match gitlab_config {
Some(cfg) => GitLab::new(&cfg).get_today_commits().await,
None => Ok(Vec::new()),
}
},
async {
match jira_config {
Some(cfg) => {
let mut jira = Jira::new(&cfg);
jira.get_completed_issues(&date_naive).await
}
None => Ok(Vec::new()),
}
},
);
spinner.finish_and_clear();
let commits = match commits_result {
Ok(c) => c,
Err(e) => {
msg_warning!(Message::GitlabFetchFailed(e.to_string()));
Vec::new()
}
};
let jira_issues = match jira_result {
Ok(issues) => issues,
Err(e) => {
msg_warning!(Message::JiraFetchFailed(e.to_string()));
Vec::new()
}
};
let mut candidates: Vec<DiscoveryItem> = Vec::new();
for task in incomplete_tasks {
if is_ignored_name(&task.name, &ignore_names) {
continue;
}
if today_names.contains(&normalize_task_name(&task.name)) {
continue;
}
candidates.push(DiscoveryItem {
source: TaskSource::Incomplete,
task,
short_sha: None,
});
}
for issue in jira_issues {
let name = format!("{} {}", issue.key, issue.fields.summary);
if is_ignored_name(&name, &ignore_names) {
continue;
}
if today_names.contains(&normalize_task_name(&name)) {
continue;
}
candidates.push(DiscoveryItem {
source: TaskSource::Jira,
task: Task::new(&name, "", Some(100)),
short_sha: None,
});
}
for commit in commits {
if is_ignored_name(&commit.message, &ignore_names) {
continue;
}
if today_names.contains(&normalize_task_name(&commit.message)) {
continue;
}
let short_sha = if commit.sha.len() >= 7 {
Some(commit.sha[..7].to_string())
} else if commit.sha.is_empty() {
None
} else {
Some(commit.sha.clone())
};
candidates.push(DiscoveryItem {
source: TaskSource::Gitlab,
task: Task::new(&commit.message, "", Some(100)),
short_sha,
});
}
let items = dedup_discovery_items(candidates);
if items.is_empty() {
msg_error!(Message::TasksNotFoundSad);
return Ok(());
}
let incomplete_count = items.iter().filter(|i| i.source == TaskSource::Incomplete).count();
let jira_count = items.iter().filter(|i| i.source == TaskSource::Jira).count();
let gitlab_count = items.iter().filter(|i| i.source == TaskSource::Gitlab).count();
msg_print!(
Message::TasksDiscoverySummary {
incomplete: incomplete_count,
jira: jira_count,
gitlab: gitlab_count,
},
true
);
let mut labels: Vec<String> = Vec::new();
let mut index_map: Vec<Option<usize>> = Vec::new();
for (idx, item) in items.iter().enumerate() {
if item.source == TaskSource::Incomplete {
labels.push(format_discovery_item(item));
index_map.push(Some(idx));
}
}
let has_rest = items.iter().any(|i| i.source != TaskSource::Incomplete);
if incomplete_count > 0 && has_rest {
labels.push(Message::TasksDiscoverySeparator.to_string());
index_map.push(None);
}
for (idx, item) in items.iter().enumerate() {
if item.source != TaskSource::Incomplete {
labels.push(format_discovery_item(item));
index_map.push(Some(idx));
}
}
let selected = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptSelectTasksToImport.to_string())
.items(&labels)
.interact()
.unwrap_or_default();
for sel in selected {
let Some(item_idx) = index_map.get(sel).copied().flatten() else {
continue; };
let Some(item) = items.get(item_idx) else {
continue;
};
let mut task = item.task.clone();
if item.source == TaskSource::Incomplete {
msg_print!(Message::SelectingTask(task.name.clone()));
if task.task_id.is_none() || task.task_id.is_some_and(|id| id == 0) {
task.task_id = task.id;
}
let default_completeness = (task.completeness.unwrap_or(0) + 1).min(100);
task.completeness = Some(
Input::with_theme(&ColorfulTheme::default())
.allow_empty(true)
.with_prompt(Message::PromptTaskCompleteness.to_string())
.default(default_completeness)
.interact_text()
.unwrap(),
);
}
let _ = Tasks::new()?.insert(&task);
}
let ignore_selected = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptSelectTasksToIgnore.to_string())
.items(&labels)
.interact()
.unwrap_or_default();
if !ignore_selected.is_empty() {
let mut names_to_ignore = Vec::new();
for sel in ignore_selected {
let Some(item_idx) = index_map.get(sel).copied().flatten() else {
continue;
};
if let Some(item) = items.get(item_idx) {
names_to_ignore.push(item.task.name.clone());
}
}
if !names_to_ignore.is_empty() {
let added = config.add_ignore_names(&names_to_ignore)?;
if added > 0 {
msg_success!(Message::TaskDiscoveryIgnoreNamesAdded(added));
}
}
}
Ok(())
}
fn prompt_task_name_interactive() -> String {
let raw = crate::libs::stdin_drain::read_pastable_line(&Message::PromptTaskName.to_string()).unwrap_or_default();
let name = collapse_whitespace(&raw);
if raw.lines().filter(|l| !l.trim().is_empty()).count() > 1 {
msg_info!(Message::TaskNameMergedFromPaste);
}
if !name.is_empty() {
println!("✔ {}", name);
}
name
}
fn looks_like_issue_key(name: &str) -> bool {
let name = name.trim();
if name.is_empty() || name.chars().any(|c| c.is_whitespace()) {
return false;
}
let Some((prefix, rest)) = name.split_once('-') else {
return false;
};
!prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_alphanumeric()) && !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
}
async fn handle_task_creation(task_args: AddArgs) -> Result<()> {
if task_args.name.is_none() {
ensure_interactive("task name is required; pass --name outside an interactive terminal")?;
}
let name_from_args = task_args.name.is_some();
let comment_from_args = task_args.comment.is_some();
let mut name = match task_args.name {
Some(n) => collapse_whitespace(&n),
None => prompt_task_name_interactive(),
};
let interactive = is_interactive();
let mut comment = match task_args.comment {
Some(c) => collapse_whitespace(&c),
None if !interactive => String::new(),
None => {
let raw: String = Input::with_theme(&ColorfulTheme::default())
.allow_empty(true)
.with_prompt(Message::PromptTaskComment.to_string())
.interact_text()
.unwrap();
collapse_whitespace(&raw)
}
};
if interactive && !name_from_args && !comment_from_args && looks_like_issue_key(&name) && !comment.is_empty() {
name = collapse_whitespace(&format!("{} {}", name, comment));
msg_info!(Message::TaskNameMergedFromPaste);
let raw: String = Input::with_theme(&ColorfulTheme::default())
.allow_empty(true)
.with_prompt(Message::PromptTaskComment.to_string())
.interact_text()
.unwrap();
comment = collapse_whitespace(&raw);
}
let _ = drain_available_stdin_lines();
let completeness = match task_args.completeness {
Some(c) => c,
None if !interactive => 100,
None => Input::with_theme(&ColorfulTheme::default())
.allow_empty(true)
.with_prompt(Message::PromptTaskCompleteness.to_string())
.default(100)
.interact_text()
.unwrap(),
};
let task = Task::new(&name, &comment, Some(completeness));
let new_task = Tasks::new()?.insert(&task)?.update_id()?.get()?;
View::tasks(&new_task)?;
if let Some(tags_str) = task_args.tags {
let tag_names: Vec<String> = tags_str.split(',').map(|s| s.trim().to_string()).collect();
let mut tags_db = crate::db::tags::Tags::new()?;
let tag_ids = tags_db.get_or_create_tags(&tag_names)?;
if let Some(task_id) = new_task[0].id {
tags_db.set_task_tags(task_id, &tag_ids)?;
msg_info!(Message::TagsAddedToTask(tag_names.join(", ")));
}
}
Ok(())
}
async fn handle_delete_by_ids(ids: Vec<i32>, assume_yes: bool) -> Result<()> {
if ids.is_empty() {
msg_error!(Message::NoTaskIdsProvided);
return Ok(());
}
let mut tasks_db = Tasks::new()?;
let tasks = tasks_db.fetch(TaskFilter::ByIds(ids.clone()))?;
if tasks.is_empty() {
msg_error!(Message::TasksNotFoundForIds(ids));
return Ok(());
}
msg_print!(Message::TasksToBeDeleted, true);
View::tasks(&tasks)?;
if !assume_yes {
ensure_interactive("refusing to remove tasks without --yes outside an interactive terminal")?;
let prompt = if ids.len() == 1 {
Message::ConfirmDeleteTask
} else {
Message::ConfirmDeleteTasks(ids.len())
};
let confirmed = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(prompt.to_string())
.default(false)
.interact()?;
if !confirmed {
msg_info!(Message::OperationCancelled);
return Ok(());
}
}
let deleted_count = tasks_db.delete_many(&ids)?;
msg_success!(Message::TasksDeletedCount(deleted_count));
Ok(())
}
async fn handle_delete_today(assume_yes: bool) -> Result<()> {
let mut tasks_db = Tasks::new()?;
let today = Local::now().date_naive();
let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
if tasks.is_empty() {
msg_info!(Message::NoTasksForToday);
return Ok(());
}
msg_print!(Message::TasksToBeDeleted, true);
View::tasks(&tasks)?;
if !assume_yes {
ensure_interactive("refusing to remove today's tasks without --yes outside an interactive terminal")?;
let first_confirm = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(Message::ConfirmDeleteAllTodayTasks(tasks.len()).to_string())
.default(false)
.interact()?;
if !first_confirm {
msg_info!(Message::OperationCancelled);
return Ok(());
}
let second_confirm = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(Message::ConfirmDeleteAllTodayTasksFinal.to_string())
.default(false)
.interact()?;
if !second_confirm {
msg_info!(Message::OperationCancelled);
return Ok(());
}
}
let ids: Vec<i32> = tasks.iter().filter_map(|t| t.id).collect();
let deleted_count = tasks_db.delete_many(&ids)?;
msg_success!(Message::TasksDeletedCount(deleted_count));
Ok(())
}
async fn handle_edit_by_id(id: i32) -> Result<()> {
ensure_interactive("`kasl task edit` is interactive and needs a terminal")?;
let mut tasks_db = Tasks::new()?;
let task = match tasks_db.get_by_id(id)? {
Some(task) => task,
None => {
msg_error!(Message::TaskNotFoundWithId(id));
return Ok(());
}
};
msg_print!(Message::CurrentTaskState, true);
View::tasks(std::slice::from_ref(&task))?;
let edited_task = edit_task_interactive(&task)?;
if edited_task.name == task.name && edited_task.comment == task.comment && edited_task.completeness == task.completeness {
msg_info!(Message::NoChangesDetected);
return Ok(());
}
msg_print!(Message::TaskEditPreview, true);
View::tasks(std::slice::from_ref(&edited_task))?;
let confirmed = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(Message::ConfirmTaskUpdate.to_string())
.default(true)
.interact()?;
if confirmed {
let mut task_to_update = task;
task_to_update.update_from(&edited_task);
tasks_db.update(&task_to_update)?;
msg_success!(Message::TaskUpdated);
} else {
msg_info!(Message::OperationCancelled);
}
Ok(())
}
async fn handle_edit_interactive() -> Result<()> {
ensure_interactive("`kasl task edit` without an id is interactive and needs a terminal")?;
let mut tasks_db = Tasks::new()?;
let today = Local::now().date_naive();
let tasks = tasks_db.fetch(TaskFilter::Date(today))?;
if tasks.is_empty() {
msg_info!(Message::NoTasksForToday);
return Ok(());
}
let task_descriptions: Vec<String> = tasks
.iter()
.map(|t| format!("[{}] {} ({}%)", t.id.unwrap_or(0), t.name, t.completeness.unwrap_or(0)))
.collect();
let selections = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt(Message::SelectTasksToEdit.to_string())
.items(&task_descriptions)
.interact()?;
if selections.is_empty() {
msg_info!(Message::NoTasksSelected);
return Ok(());
}
for &index in &selections {
let task = &tasks[index];
msg_print!(Message::EditingTask(task.name.clone()), true);
View::tasks(std::slice::from_ref(task))?;
let edited_task = edit_task_interactive(task)?;
if edited_task.name != task.name || edited_task.comment != task.comment || edited_task.completeness != task.completeness {
let mut task_to_update = task.clone();
task_to_update.update_from(&edited_task);
tasks_db.update(&task_to_update)?;
msg_success!(Message::TaskUpdatedWithName(task.name.clone()));
} else {
msg_info!(Message::TaskSkippedNoChanges(task.name.clone()));
}
}
msg_success!(Message::TaskEditingCompleted);
Ok(())
}
fn edit_task_interactive(task: &Task) -> Result<Task> {
let name = collapse_whitespace(
&Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptTaskNameEdit.to_string())
.default(task.name.clone())
.interact_text()?,
);
let comment = collapse_whitespace(
&Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptTaskCommentEdit.to_string())
.default(task.comment.clone())
.allow_empty(true)
.interact_text()?,
);
let completeness_range_msg = Message::TaskCompletenessRange.to_string();
let completeness = Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptTaskCompletenessEdit.to_string())
.default(task.completeness.unwrap_or(100))
.validate_with(|input: &i32| -> Result<(), &str> {
if *input >= 0 && *input <= 100 { Ok(()) } else { Err(&completeness_range_msg) }
})
.interact_text()?;
Ok(Task {
id: task.id,
task_id: task.task_id,
timestamp: task.timestamp.clone(),
name,
comment,
completeness: Some(completeness),
excluded_from_search: task.excluded_from_search,
tags: vec![], })
}
async fn handle_create_from_template(template_name: String) -> Result<()> {
let mut templates_db = Templates::new()?;
let template = match templates_db.get(&template_name)? {
Some(t) => t,
None => {
msg_error!(Message::TemplateNotFound(template_name));
return Ok(());
}
};
msg_info!(Message::CreatingTaskFromTemplate(template.name.clone()));
let name = Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptTaskName.to_string())
.default(template.task_name)
.interact_text()?;
let comment = Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptTaskComment.to_string())
.default(template.comment)
.allow_empty(true)
.interact_text()?;
let completeness = Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptTaskCompleteness.to_string())
.default(template.completeness)
.interact_text()?;
let task = Task::new(&name, &comment, Some(completeness));
let new_task = Tasks::new()?.insert(&task)?.update_id()?.get()?;
View::tasks(&new_task)?;
Ok(())
}
async fn handle_create_from_template_interactive() -> Result<()> {
ensure_interactive("`--from-template` is interactive; pass --template NAME outside a terminal")?;
let mut templates_db = Templates::new()?;
let templates = templates_db.get_all()?;
if templates.is_empty() {
msg_info!(Message::NoTemplatesFound);
msg_info!(Message::CreateTemplateFirst);
return Ok(());
}
let template_options: Vec<String> = templates.iter().map(|t| format!("{} - {}", t.name, t.task_name)).collect();
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt(Message::SelectTemplate.to_string())
.items(&template_options)
.interact()?;
let template = &templates[selection];
handle_create_from_template(template.name.clone()).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::libs::config::default_ignore_names;
#[test]
fn normalize_collapses_whitespace_and_punctuation() {
assert_eq!(normalize_task_name("New commit"), "new commit");
assert_eq!(normalize_task_name("New commit "), "new commit");
assert_eq!(normalize_task_name("New commit."), "new commit");
assert_eq!(normalize_task_name(" New commit"), "new commit");
assert_eq!(normalize_task_name("New commit..."), "new commit");
}
#[test]
fn collapse_whitespace_turns_newlines_into_spaces() {
let pasted = "PROJ-42\nFix login redirect for OAuth callback\r\n";
assert_eq!(collapse_whitespace(pasted), "PROJ-42 Fix login redirect for OAuth callback");
assert_eq!(collapse_whitespace(" spaced out "), "spaced out");
}
#[test]
fn looks_like_issue_key_detects_jira_keys() {
assert!(looks_like_issue_key("PROJ-42"));
assert!(looks_like_issue_key("ABC-1001"));
assert!(!looks_like_issue_key("PROJ-42 summary"));
assert!(!looks_like_issue_key("update alert"));
assert!(!looks_like_issue_key(""));
}
#[test]
fn default_ignore_filters_merge_and_update_webui_but_not_update_alert() {
let ignore = default_ignore_names();
assert!(!ignore.iter().any(|n| normalize_task_name(n) == "update alert"));
assert!(is_ignored_name("Merge remote-tracking branch 'origin/release/4.39.0' into feature/x", &ignore));
assert!(is_ignored_name("Merge branch 'main' into feature/x", &ignore));
assert!(is_ignored_name("update webui", &ignore));
assert!(is_ignored_name(" Update WebUI ", &ignore));
assert!(!is_ignored_name("update alert", &ignore));
assert!(!is_ignored_name("Fix login validation", &ignore));
}
#[test]
fn custom_ignore_list_filters_update_alert() {
let mut ignore = default_ignore_names();
ignore.push("update alert".to_string());
assert!(is_ignored_name("update alert", &ignore));
assert!(is_ignored_name("Update alert.", &ignore));
}
#[test]
fn dedup_prefers_incomplete_over_jira_over_gitlab() {
let items = vec![
DiscoveryItem {
source: TaskSource::Gitlab,
task: Task::new("New commit.", "", Some(100)),
short_sha: Some("abc1234".into()),
},
DiscoveryItem {
source: TaskSource::Jira,
task: Task::new("New commit", "", Some(100)),
short_sha: None,
},
DiscoveryItem {
source: TaskSource::Incomplete,
task: Task::new(" New commit", "", Some(40)),
short_sha: None,
},
];
let deduped = dedup_discovery_items(items);
assert_eq!(deduped.len(), 1);
assert_eq!(deduped[0].source, TaskSource::Incomplete);
assert_eq!(deduped[0].task.name, "New commit");
}
}