use chrono::{Local, NaiveDate};
use crate::cli::RecurCommand;
use crate::error::{Error, Result};
use crate::model::Priority;
use crate::recur::{self, Rule, RuleMeta, StoredRule};
use super::task::{active_library_root, confirm};
pub fn run(command: RecurCommand) -> Result<()> {
match command {
RecurCommand::Add {
title,
rule,
description,
priority,
tags,
start,
until,
max_count,
} => add(title, &rule, description, priority, tags, start, until, max_count),
RecurCommand::List => list(),
RecurCommand::Show { rule } => show(&rule),
RecurCommand::Edit { rule, editor } => edit(&rule, editor),
RecurCommand::Pause { rule } => set_enabled(&rule, false),
RecurCommand::Resume { rule } => set_enabled(&rule, true),
RecurCommand::Remove { rule, force } => remove(&rule, force),
RecurCommand::Tick => tick(),
}
}
fn parse_date(label: &str, raw: &str) -> Result<NaiveDate> {
raw.parse().map_err(|_| {
Error::InvalidTaskFile(format!("invalid {label} '{raw}', expected YYYY-MM-DD"))
})
}
#[allow(clippy::too_many_arguments)]
fn add(
title: String,
rule: &str,
description: Option<String>,
priority: Option<String>,
tags: Vec<String>,
start: Option<String>,
until: Option<String>,
max_count: Option<u64>,
) -> Result<()> {
let root = active_library_root()?;
let rule: Rule = rule.parse().map_err(Error::BadRule)?;
let start_date = match &start {
Some(s) => parse_date("start date", s)?,
None => Local::now().date_naive(),
};
let mut meta = RuleMeta::new(title, rule, start_date);
meta.description = description;
meta.tags = tags;
meta.until = until.as_deref().map(|u| parse_date("until date", u)).transpose()?;
meta.max_count = max_count;
if let Some(p) = &priority {
meta.priority = p.parse::<Priority>().map_err(Error::InvalidTaskFile)?;
} else if let Some(p) = crate::config::Config::load().ok().and_then(|c| c.default_priority) {
meta.priority = p.parse::<Priority>().map_err(Error::InvalidTaskFile)?;
}
if let Some(until) = meta.until
&& until < meta.start_date
{
return Err(Error::BadRule(format!(
"--until {until} is before the start date {}",
meta.start_date
)));
}
let path = recur::rule_path(&root, &meta);
if path.exists() {
return Err(Error::InvalidTaskFile(format!("{} already exists", path.display())));
}
let stored = StoredRule { path, meta, body: String::new() };
recur::save(&stored)?;
println!(
"created rule [{}] {} ({})",
stored.meta.short_id(),
stored.meta.title,
stored.meta.rule
);
println!(" next: {}", stored.meta.next_run);
println!(" {}", stored.path.display());
Ok(())
}
fn list() -> Result<()> {
let root = active_library_root()?;
let rules = recur::load_all(&root)?;
if rules.is_empty() {
println!("no rules; create one with 'tasks recur add <title> --rule daily'");
return Ok(());
}
println!("{:<9} {:<16} {:<12} {:<10} {:<7} TITLE", "ID", "RULE", "NEXT", "STATE", "COUNT");
for stored in &rules {
let m = &stored.meta;
let state = if !m.enabled {
"paused".to_string()
} else if m.exhausted() {
"finished".to_string()
} else {
"active".to_string()
};
let count = match m.max_count {
Some(max) => format!("{}/{max}", m.created_count),
None => m.created_count.to_string(),
};
println!(
"{:<9} {:<16} {:<12} {:<10} {:<7} {}",
m.short_id(),
m.rule.to_string(),
m.next_run.to_string(),
state,
count,
m.title
);
}
Ok(())
}
fn show(query: &str) -> Result<()> {
let root = active_library_root()?;
let stored = recur::resolve(&root, query)?;
let m = &stored.meta;
println!("[{}] {}", m.short_id(), m.title);
println!(" uuid: {}", m.id);
println!(" rule: {}", m.rule);
println!(" enabled: {}", m.enabled);
println!(" priority: {}", m.priority);
println!(" tags: {}", if m.tags.is_empty() { "-".into() } else { m.tags.join(", ") });
if let Some(d) = &m.description {
println!(" desc: {d}");
}
println!(" start: {}", m.start_date);
if let Some(until) = m.until {
println!(" until: {until}");
}
println!(" next: {}", m.next_run);
if let Some(last) = m.last_run {
println!(" last: {last}");
}
match m.max_count {
Some(max) => println!(" generated: {}/{max}", m.created_count),
None => println!(" generated: {}", m.created_count),
}
if m.exhausted() {
println!(" state: finished");
}
println!(" file: {}", stored.path.display());
if !stored.body.is_empty() {
println!("\n{}", stored.body.trim_end());
}
Ok(())
}
fn edit(query: &str, editor_flag: Option<String>) -> Result<()> {
let root = active_library_root()?;
let stored = recur::resolve(&root, query)?;
let config = crate::config::Config::load()?;
let editor = crate::editor::detect(editor_flag.as_deref(), &config)?;
let original = std::fs::read_to_string(&stored.path)?;
crate::editor::open(&editor, &stored.path)?;
let edited = std::fs::read_to_string(&stored.path)?;
match validate_edit(&stored.meta, &edited) {
Ok((mut meta, body)) => {
let schedule_changed =
meta.rule != stored.meta.rule || meta.start_date != stored.meta.start_date;
if schedule_changed && meta.next_run == stored.meta.next_run {
let from = Local::now().date_naive().max(meta.start_date);
meta.next_run = meta.rule.first_on_or_after(from, meta.start_date);
}
let title = meta.title.clone();
let next_run = meta.next_run;
let new_path = recur::rule_path(&root, &meta);
recur::save(&StoredRule { path: stored.path.clone(), meta, body })?;
if new_path != stored.path {
std::fs::rename(&stored.path, &new_path)?;
println!("renamed: {}", new_path.file_name().unwrap_or_default().to_string_lossy());
}
println!("saved rule '{title}'");
println!(" next: {next_run}");
Ok(())
}
Err(reason) => {
std::fs::write(&stored.path, original)?;
Err(Error::EditRejected(reason))
}
}
}
fn validate_edit(
before: &RuleMeta,
edited_content: &str,
) -> std::result::Result<(RuleMeta, String), String> {
let (meta, body) = recur::parse_rule(edited_content).map_err(|e| e.to_string())?;
if meta.id != before.id {
return Err(format!("field 'id' is immutable ({} -> {})", before.id, meta.id));
}
if meta.created_at != before.created_at {
return Err("field 'created_at' is immutable".into());
}
Ok((meta, body))
}
fn set_enabled(query: &str, enabled: bool) -> Result<()> {
let root = active_library_root()?;
let mut stored = recur::resolve(&root, query)?;
if stored.meta.enabled == enabled {
println!(
"rule {} is already {}",
stored.meta.title,
if enabled { "active" } else { "paused" }
);
return Ok(());
}
stored.meta.enabled = enabled;
recur::save(&stored)?;
println!(
"{} {}",
if enabled { "resumed" } else { "paused" },
stored.meta.title
);
if enabled {
println!(" next: {}", stored.meta.next_run);
}
Ok(())
}
fn remove(query: &str, force: bool) -> Result<()> {
let root = active_library_root()?;
let stored = recur::resolve(&root, query)?;
if !force && !confirm(&format!("delete rule '{}'?", stored.meta.title))? {
println!("aborted");
return Ok(());
}
std::fs::remove_file(&stored.path)?;
println!("removed rule '{}'", stored.meta.title);
Ok(())
}
fn tick() -> Result<()> {
let root = active_library_root()?;
let generated = recur::run_due(&root, Local::now().date_naive())?;
if generated.is_empty() {
println!("no rules are due");
return Ok(());
}
for g in &generated {
println!("created task #{} {} (for {})", g.seq, g.title, g.occurrence);
println!(" {}", g.path.display());
}
println!("generated {} task(s)", generated.len());
Ok(())
}
pub fn autorun() {
let Ok(root) = active_library_root() else { return };
match recur::run_due(&root, Local::now().date_naive()) {
Ok(generated) if !generated.is_empty() => {
let summary: Vec<String> =
generated.iter().map(|g| format!("#{} {}", g.seq, g.title)).collect();
eprintln!(
"generated {} recurring task(s): {}",
generated.len(),
summary.join(", ")
);
}
Ok(_) => {}
Err(err) => eprintln!("warning: recurring task generation failed: {err}"),
}
}