use time::{Date, Month};
use crate::cli::{
RecurringAction, RecurringCreateArgs, RecurringEditArgs, RecurringLifecycleArgs,
RecurringRuleArgs,
};
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::model::{SeriesRef, SeriesRule};
use crate::recurrence::detect::{Detection, detect};
use crate::recurrence::generate::{GenerationOutcome, GenerationRequest, generate};
use crate::recurrence::lifecycle::{LifecycleAction, LifecycleOutcome, LifecycleRequest, apply};
use crate::recurrence::rule::{DateKey, RuleContext, Timezone};
use crate::recurrence::series::{OccurrenceKey, Series, SeriesId, SeriesIndex};
use crate::storage::Store;
pub fn run(config: &Config, store: &Store, action: &RecurringAction) -> Result<()> {
let issues = store.list()?;
let index = SeriesIndex::build(&issues);
match action {
RecurringAction::List => list(&index),
RecurringAction::Show { series } => show(&index, series),
RecurringAction::Create(args) => create(config, store, args),
RecurringAction::Edit { series, args } => edit(store, &index, series, args),
RecurringAction::Detect { date, timezone } => {
detect_now(&index, date.as_deref(), timezone.as_deref())
}
RecurringAction::Generate {
series,
date,
confirmation,
} => generate_now(config, store, &index, series, date, confirmation),
RecurringAction::Pause {
series,
confirmation,
} => lifecycle(store, series, LifecycleAction::Pause, None, confirmation),
RecurringAction::Resume {
series,
confirmation,
} => lifecycle(store, series, LifecycleAction::Resume, None, confirmation),
RecurringAction::End {
series,
confirmation,
} => lifecycle(store, series, LifecycleAction::End, None, confirmation),
RecurringAction::Skip {
series,
date,
confirmation,
} => lifecycle(
store,
series,
LifecycleAction::Skip,
Some(date),
confirmation,
),
RecurringAction::Defer {
series,
date,
confirmation,
} => lifecycle(
store,
series,
LifecycleAction::Defer,
Some(date),
confirmation,
),
RecurringAction::Ignore {
series,
date,
confirmation,
} => lifecycle(
store,
series,
LifecycleAction::Ignore,
Some(date),
confirmation,
),
}
}
fn list(index: &SeriesIndex) -> Result<()> {
println!("id\tname\tstate\tnext_cursor\tskipped");
for series in index.series() {
println!(
"{}\t{}\t{}\t{}\t{}",
series.id,
series.name,
series.state.as_str(),
series
.next_cursor
.as_ref()
.map(DateKey::as_str)
.unwrap_or("-"),
series.skipped.len()
);
}
Ok(())
}
fn show(index: &SeriesIndex, selector: &str) -> Result<()> {
let series = resolve(index, selector)?;
println!("id: {}", series.id);
println!("name: {}", series.name);
println!("state: {}", series.state.as_str());
println!("rule: {}", series.rule.summary());
println!(
"anchor: {}",
series
.rule
.anchor_key()
.map(|key| key.as_str().to_string())
.unwrap_or_else(|_| "(invalid)".to_string())
);
println!(
"next_cursor: {}",
series
.next_cursor
.as_ref()
.map(DateKey::as_str)
.unwrap_or("-")
);
println!(
"skipped: {}",
if series.skipped.is_empty() {
"-".to_string()
} else {
series
.skipped
.iter()
.map(DateKey::as_str)
.collect::<Vec<_>>()
.join(",")
}
);
println!("issue: #{} ({})", series.issue_number, series.filename);
let occurrences: Vec<String> = index
.occurrences()
.filter(|record| record.series_id == series.id)
.map(|record| record.date.as_str().to_string())
.collect();
println!(
"occurrences: {}",
if occurrences.is_empty() {
"-".to_string()
} else {
occurrences.join(",")
}
);
for diagnostic in index.diagnostics() {
eprintln!("{}", diagnostic.render());
}
Ok(())
}
fn detect_now(index: &SeriesIndex, date: Option<&str>, timezone: Option<&str>) -> Result<()> {
let context = context_for(date, timezone)?;
let detection = detect(index, &context);
print_detection(&detection);
Ok(())
}
fn print_detection(detection: &Detection) {
if detection.is_empty() {
println!(
"No occurrences due across {} series.",
detection.series_examined()
);
} else {
println!("series_id\toccurrence_key\tdue_date\trule");
for warning in detection.warnings() {
println!(
"{}\t{}\t{}\t{}",
warning.series_id, warning.occurrence_key, warning.due_date, warning.rule_summary
);
}
}
for note in detection.notes() {
println!(
"note: {}\t{}\t{}",
note.kind.as_str(),
note.series_id,
note.message
);
}
}
pub(crate) struct CreatedSeries {
pub id: String,
pub issue_number: u32,
pub filename: String,
pub rule: String,
}
pub(crate) fn create_series(
config: &Config,
store: &Store,
args: &RecurringCreateArgs,
) -> Result<CreatedSeries> {
let id = match args.id.as_deref() {
Some(raw) => SeriesId::parse(raw).map_err(|error| refusal(&error.first().render()))?,
None => SeriesId::generate(),
};
let name = args.name.clone().unwrap_or_else(|| args.title.clone());
let reference = SeriesRef {
id: id.as_str().to_string(),
name: Some(name.clone()),
date: None,
key: None,
rule: Some(rule_from_args(&args.rule, SeriesRule::default())),
state: None,
next_cursor: None,
skipped: Vec::new(),
};
let definition = Series::from_ref(&reference, 0, "")
.and_then(|series| {
series.ok_or_else(|| {
crate::recurrence::series::SeriesError::single(
crate::recurrence::series::SeriesDiagnostic::new(
crate::recurrence::series::SeriesDiagnosticKind::MissingField,
"series.rule",
"a series definition needs a rule",
),
)
})
})
.map_err(|error| refusal(&error.first().render()))?;
let created = store.transaction(|| {
let mut issue = crate::commands::create::create(
config,
store,
&args.title,
args.body.as_deref(),
args.status.clone().map(crate::model::Status::new),
args.priority
.as_deref()
.map(crate::commands::parse_priority)
.transpose()?,
args.assignee.as_deref(),
args.labels.as_deref(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)?;
issue.series = Some(reference.clone());
let filename = store.update(&issue.filename, &issue)?;
Ok((issue, filename))
})?;
let (issue, filename) = created;
Ok(CreatedSeries {
id: definition.id.to_string(),
issue_number: issue.number,
filename,
rule: definition.rule.summary(),
})
}
fn create(config: &Config, store: &Store, args: &RecurringCreateArgs) -> Result<()> {
let created = create_series(config, store, args)?;
println!("Created series {}", created.id);
println!(" issue: #{} ({})", created.issue_number, created.filename);
println!(" rule: {}", created.rule);
Ok(())
}
fn edit(
store: &Store,
index: &SeriesIndex,
selector: &str,
args: &RecurringEditArgs,
) -> Result<()> {
let series = resolve(index, selector)?;
let issues = store.list()?;
let definition = issues
.iter()
.find(|issue| issue.number == series.issue_number)
.ok_or_else(|| {
TixError::NotFound(format!(
"issue #{} defining series `{}`",
series.issue_number, series.id
))
})?;
let mut updated = definition.clone();
let mut reference = updated.series.clone().ok_or_else(|| {
TixError::Refused(format!(
"issue #{} no longer carries a series table",
updated.number
))
})?;
if let Some(name) = args.name.as_deref() {
reference.name = Some(name.to_string());
}
if args.rule.is_any() {
let stored = reference.rule.clone().unwrap_or_default();
reference.rule = Some(rule_from_args(&args.rule, stored));
let candidate = SeriesRef {
id: reference.id.clone(),
name: reference.name.clone().or_else(|| Some(series.name.clone())),
..reference.clone()
};
Series::from_ref(&candidate, updated.number, &updated.filename)
.map_err(|error| refusal(&error.first().render()))?;
}
updated.series = Some(reference);
let filename = store.transaction(|| store.update(&updated.filename, &updated))?;
let name = updated
.series
.as_ref()
.and_then(|reference| reference.name.clone())
.unwrap_or_default();
println!("Updated series {}", series.id);
println!(" name: {name}");
println!(" file: {filename}");
Ok(())
}
fn generate_now(
config: &Config,
store: &Store,
index: &SeriesIndex,
selector: &str,
date: &str,
confirmation: &RecurringLifecycleArgs,
) -> Result<()> {
let series = resolve(index, selector)?;
let context = context_for(Some(date), None)?;
let key = OccurrenceKey::new(&series.id, &DateKey::from_date(context.today));
let request = if confirmation.yes {
GenerationRequest::new(series.id.clone(), key, actor()).confirm()
} else {
GenerationRequest::new(series.id.clone(), key, actor())
};
let outcome = generate(config, store, index, &request)?;
match outcome {
GenerationOutcome::Created {
issue_number,
filename,
ref key,
..
} => {
println!("Created occurrence #{issue_number} for {key}");
println!(" file: {filename}");
}
GenerationOutcome::NoOp {
ref key, message, ..
} => {
println!("No-op for {key}: {message}");
}
GenerationOutcome::Rejected { .. } => {
return Err(TixError::Refused(outcome.render()));
}
}
Ok(())
}
fn lifecycle(
store: &Store,
selector: &str,
action: LifecycleAction,
date: Option<&str>,
confirmation: &RecurringLifecycleArgs,
) -> Result<()> {
let issues = store.list()?;
let index = SeriesIndex::build(&issues);
let series = resolve(&index, selector)?;
let mut request = LifecycleRequest::new(series.id.clone(), action, actor());
if let Some(date) = date {
let context = context_for(Some(date), None)?;
request = request.about(OccurrenceKey::new(
&series.id,
&DateKey::from_date(context.today),
));
}
if let Some(reason) = confirmation.reason.as_deref() {
request = request.because(reason);
}
if confirmation.yes {
request = request.confirm();
}
let outcome = apply(store, &request)?;
match &outcome {
LifecycleOutcome::Applied {
action,
state,
detail,
..
} => {
println!(
"{}: series {} is now {}",
action.as_str(),
series.id,
state.as_str()
);
println!(" {detail}");
}
LifecycleOutcome::NoOp {
action, message, ..
} => {
println!("{}: no-op: {message}", action.as_str());
}
LifecycleOutcome::Rejected { .. } => return Err(TixError::Refused(outcome.render())),
}
Ok(())
}
pub(crate) fn resolve(index: &SeriesIndex, selector: &str) -> Result<Series> {
let trimmed = selector.trim();
if let Ok(id) = SeriesId::parse(trimmed)
&& let Some(series) = index.series_by_id(&id)
{
return Ok(series.clone());
}
let matches: Vec<&Series> = index
.series()
.filter(|series| series.name == trimmed)
.collect();
match matches.as_slice() {
[series] => Ok((*series).clone()),
[] => Err(TixError::NotFound(format!("series `{trimmed}`"))),
several => Err(TixError::InvalidInput(format!(
"series name `{trimmed}` is ambiguous: {}",
several
.iter()
.map(|series| series.id.as_str())
.collect::<Vec<_>>()
.join(", ")
))),
}
}
pub(crate) fn context_for(date: Option<&str>, timezone: Option<&str>) -> Result<RuleContext> {
let today = match date {
Some(raw) => parse_date(raw)?,
None => parse_date(&crate::model::today())?,
};
let context = RuleContext::on(today);
Ok(match timezone {
Some(raw) => context.with_timezone(Timezone::parse(raw)),
None => context,
})
}
fn parse_date(raw: &str) -> Result<Date> {
let raw = raw.trim();
if !crate::model::validate_date(raw) {
return Err(TixError::InvalidInput(format!(
"`{raw}` is not a `YYYY-MM-DD` calendar date"
)));
}
let year: i32 = raw[0..4].parse().map_err(|_| date_error(raw))?;
let month: u8 = raw[5..7].parse().map_err(|_| date_error(raw))?;
let day: u8 = raw[8..10].parse().map_err(|_| date_error(raw))?;
Date::from_calendar_date(
year,
Month::try_from(month).map_err(|_| date_error(raw))?,
day,
)
.map_err(|_| date_error(raw))
}
fn date_error(raw: &str) -> TixError {
TixError::InvalidInput(format!("`{raw}` is not a `YYYY-MM-DD` calendar date"))
}
fn rule_from_args(args: &RecurringRuleArgs, base: SeriesRule) -> SeriesRule {
SeriesRule {
kind: args.rule.clone().or(base.kind),
interval: args.interval.or(base.interval),
anchor: args.anchor.clone().or(base.anchor),
timezone: args.timezone.clone().or(base.timezone),
expression: args.expression.clone().or(base.expression),
unit: args.unit.clone().or(base.unit),
extensions: base.extensions,
}
}
pub(crate) fn actor() -> String {
crate::assignee::git_identity()
.map(|identity| identity.to_string())
.or_else(|| std::env::var("USER").ok())
.or_else(|| std::env::var("USERNAME").ok())
.unwrap_or_else(|| "tix".to_string())
}
fn refusal(rendered: &str) -> TixError {
TixError::Refused(rendered.to_string())
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
#[test]
fn the_rule_flags_overlay_the_stored_rule_field_by_field() {
let base = SeriesRule {
kind: Some("interval".to_string()),
interval: Some(3),
anchor: Some("2026-01-01".to_string()),
unit: Some("weeks".to_string()),
..SeriesRule::default()
};
let args = RecurringRuleArgs {
rule: None,
anchor: None,
interval: Some(5),
unit: None,
expression: None,
timezone: Some("+02:00".to_string()),
};
let merged = rule_from_args(&args, base);
assert_eq!(
merged.kind.as_deref(),
Some("interval"),
"unnamed fields stay"
);
assert_eq!(merged.interval, Some(5), "the named field changes");
assert_eq!(merged.unit.as_deref(), Some("weeks"));
assert_eq!(merged.timezone.as_deref(), Some("+02:00"));
}
#[test]
fn a_date_that_is_not_a_calendar_date_is_refused_at_the_boundary() {
for raw in ["", "2026-1-1", "2026-02-30", "yesterday"] {
let error = parse_date(raw).unwrap_err().to_string();
assert!(
error.contains("YYYY-MM-DD"),
"`{raw}` must be refused with the format: {error}"
);
}
assert_eq!(
parse_date("2026-02-28").unwrap(),
Date::from_calendar_date(2026, Month::February, 28).unwrap()
);
}
#[test]
fn an_explicit_date_wins_over_today() {
let context = context_for(Some("2026-03-01"), None).unwrap();
assert_eq!(DateKey::from_date(context.today).as_str(), "2026-03-01");
let tz = context_for(Some("2026-03-01"), Some("+02:00")).unwrap();
assert!(tz.timezone.is_some(), "the timezone policy is carried");
}
}