use anyhow::Result;
use chrono::Utc;
use rusqlite::Connection;
use serde_json::{Value, json};
use crate::infrastructure::config::Config;
use crate::infrastructure::db;
use crate::infrastructure::model::Task;
use crate::infrastructure::tui;
use crate::infrastructure::tui::review_form::{FormContext, FormInput, run_form};
#[allow(clippy::too_many_arguments)]
pub fn run(
conn: &Connection,
cfg: &Config,
id_or_uuid: &str,
description: Option<&str>,
priority: Option<&str>,
due: Option<&str>,
clear_due: bool,
tags: &[String],
clear_tags: bool,
estimate: Option<&str>,
clear_estimate: bool,
every: Option<&str>,
clear_recur: bool,
) -> Result<()> {
let task = db::resolve_task(conn, id_or_uuid)?;
let has_field_flags = description.is_some()
|| priority.is_some()
|| due.is_some()
|| clear_due
|| !tags.is_empty()
|| clear_tags
|| estimate.is_some()
|| clear_estimate
|| every.is_some()
|| clear_recur;
if has_field_flags {
return apply_fields(
conn,
cfg,
task,
description,
priority,
due,
clear_due,
tags,
clear_tags,
estimate,
clear_estimate,
every,
clear_recur,
);
}
let pending = db::list_tasks(conn, None)?;
let available_deps: Vec<(String, String)> = pending
.iter()
.filter(|t| t.uuid != task.uuid)
.map(|t| {
let id = format!("{}", t.id.unwrap_or(0));
(id, t.description.clone())
})
.collect();
let project_files: Vec<String> = db::get_project(conn, &task.project)?
.and_then(|p| p.path)
.map(|p| crate::infrastructure::files::collect_project_entries(std::path::Path::new(&p)))
.unwrap_or_default();
let current_files = db::get_task_files(conn, &task.uuid)?;
let due_str = task
.due
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_default();
let ctx = FormContext {
initial: FormInput {
description: task.description.clone(),
project: task.project.clone(),
priority: task.priority.clone(),
due: due_str,
tags: task.tags.join(","),
selected_deps: vec![],
selected_files: current_files,
},
available_deps,
available_files: project_files,
suggested_dep_indices: vec![],
suggested_files: vec![],
};
let mut terminal = tui::init_terminal()?;
let result = run_form(&mut terminal, ctx);
tui::restore_terminal()?;
let Some(form) = result? else {
println!("Cancelled.");
return Ok(());
};
let mut updated = task.clone();
updated.description = form.description;
updated.project = form.project.clone();
updated.priority = form.priority;
updated.tags = form
.tags
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
updated.modified = Utc::now();
if form.due.is_empty() {
updated.due = None;
} else {
updated.due = crate::commands::add::parse_due(&form.due, cfg);
}
updated.urgency = db::compute_urgency(&updated, &cfg.urgency, false, 0);
db::update_task(conn, &updated)?;
db::set_task_files(conn, &updated.uuid, &form.selected_files)?;
db::refresh_urgency(conn, &cfg.urgency, &updated.uuid)?;
println!(
"Updated task {}: {}",
updated.id.unwrap_or(0),
updated.description
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn modify_value(
conn: &Connection,
cfg: &Config,
id_or_uuid: &str,
description: Option<&str>,
priority: Option<&str>,
due: Option<&str>,
clear_due: bool,
tags: &[String],
clear_tags: bool,
estimate: Option<&str>,
clear_estimate: bool,
every: Option<&str>,
clear_recur: bool,
) -> Result<Value> {
let has_field = description.is_some()
|| priority.is_some()
|| due.is_some()
|| clear_due
|| !tags.is_empty()
|| clear_tags
|| estimate.is_some()
|| clear_estimate
|| every.is_some()
|| clear_recur;
anyhow::ensure!(
has_field,
"modify requires at least one field to change (description, priority, due, clear_due, tags, clear_tags, estimate, clear_estimate, every, or clear_recur)"
);
let task = db::resolve_task(conn, id_or_uuid)?;
let mut updated = merge_task_fields(
task,
cfg,
description,
priority,
due,
clear_due,
tags,
clear_tags,
estimate,
clear_estimate,
every,
clear_recur,
)?;
updated.urgency = db::compute_urgency(&updated, &cfg.urgency, false, 0);
db::update_task(conn, &updated)?;
db::refresh_urgency(conn, &cfg.urgency, &updated.uuid)?;
Ok(json!({
"task": updated.id,
"uuid": updated.uuid.to_string(),
"description": updated.description,
"priority": updated.priority.as_ref().map(|p| p.label()),
"due": updated.due.map(|d| d.format("%Y-%m-%d").to_string()),
"tags": updated.tags,
"estimate_mins": updated.estimate_mins,
"recur": updated.recur,
}))
}
#[allow(clippy::too_many_arguments)]
fn apply_fields(
conn: &Connection,
cfg: &Config,
task: Task,
description: Option<&str>,
priority: Option<&str>,
due: Option<&str>,
clear_due: bool,
tags: &[String],
clear_tags: bool,
estimate: Option<&str>,
clear_estimate: bool,
every: Option<&str>,
clear_recur: bool,
) -> Result<()> {
let uuid = task.uuid.to_string();
let v = modify_value(
conn,
cfg,
&uuid,
description,
priority,
due,
clear_due,
tags,
clear_tags,
estimate,
clear_estimate,
every,
clear_recur,
)?;
println!(
"Updated task {}: {}",
v["task"].as_i64().unwrap_or(0),
v["description"].as_str().unwrap_or_default()
);
Ok(())
}
fn parse_estimate_mins(s: &str) -> Option<i64> {
let s = s.trim();
if s.is_empty() {
return None;
}
let s_lower = s.to_lowercase();
let rest = s_lower.as_str();
if let Some(h_pos) = rest.find('h')
&& let Ok(h) = rest[..h_pos].trim().parse::<i64>()
{
let mut total = h * 60;
let after_h = rest[h_pos + 1..].trim().trim_end_matches('m').trim();
if !after_h.is_empty()
&& let Ok(m) = after_h.parse::<i64>()
{
total += m;
}
return Some(total);
}
let m_part = rest.trim_end_matches('m').trim();
m_part.parse::<i64>().ok()
}
#[allow(clippy::too_many_arguments)]
fn merge_task_fields(
task: Task,
cfg: &Config,
description: Option<&str>,
priority: Option<&str>,
due: Option<&str>,
clear_due: bool,
tags: &[String],
clear_tags: bool,
estimate: Option<&str>,
clear_estimate: bool,
every: Option<&str>,
clear_recur: bool,
) -> Result<Task> {
let mut updated = task;
if let Some(d) = description {
updated.description = d.to_string();
}
if let Some(p) = priority {
updated.priority = Some(
p.parse()
.map_err(|_| anyhow::anyhow!("Unknown priority: {p} (expected H, M, or L)"))?,
);
}
if clear_tags {
updated.tags = vec![];
} else if !tags.is_empty() {
updated.tags = tags
.iter()
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
}
if clear_due {
updated.due = None;
} else if let Some(d) = due {
match crate::commands::add::parse_due(d, cfg) {
Some(dt) => updated.due = Some(dt),
None => anyhow::bail!("Could not parse due date: {d}"),
}
}
if clear_estimate {
updated.estimate_mins = None;
} else if let Some(e) = estimate {
match parse_estimate_mins(e) {
Some(mins) => updated.estimate_mins = Some(mins),
None => anyhow::bail!(
"Could not parse estimate: {e} (expected e.g. \"90m\", \"2h\", \"2h30m\")"
),
}
}
if clear_recur {
updated.recur = None;
} else if let Some(r) = every {
updated.recur = if r.trim().is_empty() {
None
} else {
Some(r.trim().to_string())
};
}
updated.modified = Utc::now();
Ok(updated)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::model::{Priority, Status, Task};
use uuid::Uuid;
fn sample() -> Task {
Task {
uuid: Uuid::new_v4(),
id: Some(1),
description: "orig".into(),
project: "p".into(),
status: Status::Pending,
priority: None,
due: None,
entry: Utc::now(),
modified: Utc::now(),
end: None,
tags: vec!["old".into()],
urgency: 0.0,
started_at: None,
time_spent: 0,
estimate_mins: None,
recur: None,
}
}
#[test]
fn sets_description_priority_and_replaces_tags() {
let cfg = Config::default();
let t = merge_task_fields(
sample(),
&cfg,
Some("new desc"),
Some("h"),
None,
false,
&["a".into(), "b".into()],
false,
None,
false,
None,
false,
)
.unwrap();
assert_eq!(t.description, "new desc");
assert_eq!(t.priority, Some(Priority::H));
assert_eq!(t.tags, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn clear_tags_and_clear_due_unset_fields() {
let cfg = Config::default();
let mut base = sample();
base.due = Some(Utc::now());
let t = merge_task_fields(
base,
&cfg,
None,
None,
None,
true,
&[],
true,
None,
false,
None,
false,
)
.unwrap();
assert!(t.tags.is_empty());
assert!(t.due.is_none());
}
#[test]
fn invalid_priority_is_rejected() {
let cfg = Config::default();
assert!(
merge_task_fields(
sample(),
&cfg,
None,
Some("X"),
None,
false,
&[],
false,
None,
false,
None,
false
)
.is_err()
);
}
#[test]
fn invalid_due_is_rejected() {
let cfg = Config::default();
assert!(
merge_task_fields(
sample(),
&cfg,
None,
None,
Some("not-a-date"),
false,
&[],
false,
None,
false,
None,
false
)
.is_err()
);
}
#[test]
fn unspecified_fields_are_left_unchanged() {
let cfg = Config::default();
let t = merge_task_fields(
sample(),
&cfg,
None,
None,
None,
false,
&[],
false,
None,
false,
None,
false,
)
.unwrap();
assert_eq!(t.description, "orig");
assert_eq!(t.priority, None);
assert_eq!(t.tags, vec!["old".to_string()]);
}
#[test]
fn sets_estimate_and_recur() {
let cfg = Config::default();
let t = merge_task_fields(
sample(),
&cfg,
None,
None,
None,
false,
&[],
false,
Some("2h30m"),
false,
Some("weekly"),
false,
)
.unwrap();
assert_eq!(t.estimate_mins, Some(150));
assert_eq!(t.recur, Some("weekly".to_string()));
}
#[test]
fn clear_estimate_and_clear_recur_unset_fields() {
let cfg = Config::default();
let mut base = sample();
base.estimate_mins = Some(90);
base.recur = Some("daily".into());
let t = merge_task_fields(
base,
&cfg,
None,
None,
None,
false,
&[],
false,
None,
true,
None,
true,
)
.unwrap();
assert!(t.estimate_mins.is_none());
assert!(t.recur.is_none());
}
#[test]
fn invalid_estimate_is_rejected() {
let cfg = Config::default();
assert!(
merge_task_fields(
sample(),
&cfg,
None,
None,
None,
false,
&[],
false,
Some("not-a-duration"),
false,
None,
false
)
.is_err()
);
}
}