#[allow(clippy::wildcard_imports)]
use super::*;
pub(super) fn defer_recurring_occurrence(
_config: &Config,
store: &Store,
args: &Value,
) -> ToolOutcome {
lifecycle_tool(store, args, LifecycleAction::Defer)
}
pub(super) fn detect_recurring_due(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let date = opt_string(args, "date")?;
let timezone = opt_string(args, "timezone")?;
let index = match recurring_index(store) {
Ok(index) => index,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let context = match cmd_recurring::context_for(date.as_deref(), timezone.as_deref()) {
Ok(context) => context,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let detection = detect(&index, &context);
Ok(success_result(
detection_text(&detection),
detection_value(&detection, &context),
))
}
pub(super) fn end_recurring_series(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
lifecycle_tool(store, args, LifecycleAction::End)
}
pub(super) fn generate_recurring_occurrence(
config: &Config,
store: &Store,
args: &Value,
) -> ToolOutcome {
let raw = req_string(args, "series")?;
let date = req_string(args, "date")?;
let actor = recurring_actor(args)?;
let confirmed = recurring_confirmed(args)?;
let index = match recurring_index(store) {
Ok(index) => index,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let series = match cmd_recurring::resolve(&index, &raw) {
Ok(series) => series,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let context = match cmd_recurring::context_for(Some(&date), None) {
Ok(context) => context,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let key = OccurrenceKey::new(&series.id, &DateKey::from_date(context.today));
let request = GenerationRequest::new(series.id.clone(), key, actor);
let request = if confirmed {
request.confirm()
} else {
request
};
match generate(config, store, &index, &request) {
Ok(outcome @ GenerationOutcome::Rejected { .. }) => Ok(rejected_result(
crate::error::render_error(&crate::error::TixError::Refused(outcome.render())),
generation_value(&outcome),
)),
Ok(outcome) => Ok(success_result(
generation_text(&outcome),
generation_value(&outcome),
)),
Err(error) => Ok(error_result(crate::error::render_error(&error))),
}
}
pub(super) fn get_recurring_series(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let raw = req_string(args, "series")?;
let index = match recurring_index(store) {
Ok(index) => index,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let series = match cmd_recurring::resolve(&index, &raw) {
Ok(series) => series,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let occurrences: Vec<&crate::recurrence::series::OccurrenceRecord> = index
.occurrences()
.filter(|record| record.series_id == series.id)
.collect();
let dates: Vec<&str> = occurrences
.iter()
.map(|record| record.date.as_str())
.collect();
let (diagnostics_text, diagnostics) = series_diagnostics(&index);
let mut text = series_detail_text(&series, &dates);
text.push_str(&diagnostics_text);
Ok(success_result(
text,
json!({
"series": series_value(&series),
"occurrences": occurrences
.iter()
.map(|record| {
json!({
"key": record.key.as_str(),
"date": record.date.as_str(),
"issue_number": record.issue_number,
"filename": record.filename,
})
})
.collect::<Vec<_>>(),
"diagnostics": diagnostics,
}),
))
}
pub(super) fn ignore_recurring_occurrence(
_config: &Config,
store: &Store,
args: &Value,
) -> ToolOutcome {
lifecycle_tool(store, args, LifecycleAction::Ignore)
}
pub(super) fn list_recurring_series(_config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
let index = match recurring_index(store) {
Ok(index) => index,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let (diagnostics_text, diagnostics) = series_diagnostics(&index);
let mut text = series_rows_text(&index);
text.push_str(&diagnostics_text);
Ok(success_result(
text,
json!({
"series": index.series().map(series_value).collect::<Vec<_>>(),
"series_count": index.series_count(),
"diagnostics": diagnostics,
}),
))
}
pub(super) fn pause_recurring_series(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
lifecycle_tool(store, args, LifecycleAction::Pause)
}
pub(super) fn resume_recurring_series(
_config: &Config,
store: &Store,
args: &Value,
) -> ToolOutcome {
lifecycle_tool(store, args, LifecycleAction::Resume)
}
pub(super) fn skip_recurring_occurrence(
_config: &Config,
store: &Store,
args: &Value,
) -> ToolOutcome {
lifecycle_tool(store, args, LifecycleAction::Skip)
}
pub(super) fn dispatch(
config: &Config,
store: &Store,
name: &str,
arguments: &Value,
) -> Option<ToolOutcome> {
match name {
"list_recurring_series" => Some(list_recurring_series(config, store, arguments)),
"get_recurring_series" => Some(get_recurring_series(config, store, arguments)),
"detect_recurring_due" => Some(detect_recurring_due(config, store, arguments)),
"generate_recurring_occurrence" => {
Some(generate_recurring_occurrence(config, store, arguments))
}
"pause_recurring_series" => Some(pause_recurring_series(config, store, arguments)),
"resume_recurring_series" => Some(resume_recurring_series(config, store, arguments)),
"end_recurring_series" => Some(end_recurring_series(config, store, arguments)),
"skip_recurring_occurrence" => Some(skip_recurring_occurrence(config, store, arguments)),
"defer_recurring_occurrence" => Some(defer_recurring_occurrence(config, store, arguments)),
"ignore_recurring_occurrence" => {
Some(ignore_recurring_occurrence(config, store, arguments))
}
_ => None,
}
}
pub(super) fn recurring_index(store: &Store) -> TixResult<SeriesIndex> {
Ok(SeriesIndex::build(&store.list()?))
}
pub(super) fn series_value(series: &Series) -> Value {
json!({
"id": series.id.as_str(),
"name": series.name,
"state": series.state.as_str(),
"rule": series.rule.summary(),
"rule_kind": series.rule.kind.as_str(),
"anchor": match series.rule.anchor_key() {
Ok(key) => Value::String(key.as_str().to_string()),
Err(_) => Value::Null,
},
"next_cursor": series.next_cursor.as_ref().map(DateKey::as_str),
"skipped": series.skipped.iter().map(DateKey::as_str).collect::<Vec<_>>(),
"issue_number": series.issue_number,
"filename": series.filename,
})
}
pub(super) fn series_rows_text(index: &SeriesIndex) -> String {
let mut out = String::from("id\tname\tstate\tnext_cursor\tskipped\n");
for series in index.series() {
out.push_str(&format!(
"{}\t{}\t{}\t{}\t{}\n",
series.id,
series.name,
series.state.as_str(),
series
.next_cursor
.as_ref()
.map(DateKey::as_str)
.unwrap_or("-"),
series.skipped.len()
));
}
out
}
pub(super) fn series_detail_text(series: &Series, occurrences: &[&str]) -> String {
let anchor = match series.rule.anchor_key() {
Ok(key) => key.as_str().to_string(),
Err(_) => "(invalid)".to_string(),
};
let skipped = if series.skipped.is_empty() {
"-".to_string()
} else {
series
.skipped
.iter()
.map(DateKey::as_str)
.collect::<Vec<_>>()
.join(",")
};
let occurrences = if occurrences.is_empty() {
"-".to_string()
} else {
occurrences.join(",")
};
format!(
"id: {}\nname: {}\nstate: {}\nrule: {}\nanchor: {}\nnext_cursor: {}\nskipped: {}\n\
issue: #{} ({})\noccurrences: {}\n",
series.id,
series.name,
series.state.as_str(),
series.rule.summary(),
anchor,
series
.next_cursor
.as_ref()
.map(DateKey::as_str)
.unwrap_or("-"),
skipped,
series.issue_number,
series.filename,
occurrences
)
}
pub(super) fn series_diagnostics(index: &SeriesIndex) -> (String, Value) {
let mut text = String::new();
for diagnostic in index.diagnostics() {
text.push_str(&diagnostic.render());
text.push('\n');
}
let values: Vec<Value> = index
.diagnostics()
.iter()
.map(|diagnostic| {
json!({
"kind": diagnostic.kind.as_str(),
"issue_number": diagnostic.issue_number,
"filename": diagnostic.filename,
"field": diagnostic.field,
"message": diagnostic.message,
})
})
.collect();
(text, Value::Array(values))
}
pub(super) fn detection_text(detection: &Detection) -> String {
let mut out = String::new();
if detection.is_empty() {
out.push_str(&format!(
"No occurrences due across {} series.\n",
detection.series_examined()
));
} else {
out.push_str("series_id\toccurrence_key\tdue_date\trule\n");
for warning in detection.warnings() {
out.push_str(&format!(
"{}\t{}\t{}\t{}\n",
warning.series_id, warning.occurrence_key, warning.due_date, warning.rule_summary
));
}
}
for note in detection.notes() {
out.push_str(&format!(
"note: {}\t{}\t{}\n",
note.kind.as_str(),
note.series_id,
note.message
));
}
out
}
pub(super) fn detection_value(detection: &Detection, context: &RuleContext) -> Value {
json!({
"date": context.today.to_string(),
"timezone": context.timezone.as_ref().map(ToString::to_string),
"series_examined": detection.series_examined(),
"warnings": detection
.warnings()
.iter()
.map(|warning| {
json!({
"summary": warning.summary(),
"series_id": warning.series_id.as_str(),
"series_name": warning.series_name,
"occurrence_key": warning.occurrence_key.as_str(),
"due_date": warning.due_date.as_str(),
"rule": warning.rule_summary,
"available_actions": warning
.available_actions
.iter()
.map(|action| action.as_str())
.collect::<Vec<_>>(),
})
})
.collect::<Vec<_>>(),
"notes": detection
.notes()
.iter()
.map(|note| {
json!({
"kind": note.kind.as_str(),
"series_id": note.series_id.as_str(),
"message": note.message,
})
})
.collect::<Vec<_>>(),
})
}
pub(super) fn generation_text(outcome: &GenerationOutcome) -> String {
match outcome {
GenerationOutcome::Created {
key,
issue_number,
filename,
..
} => format!("Created occurrence #{issue_number} for {key}\n file: {filename}\n"),
GenerationOutcome::NoOp { key, message, .. } => {
format!("No-op for {key}: {message}\n")
}
GenerationOutcome::Rejected { .. } => outcome.render(),
}
}
pub(super) fn generation_value(outcome: &GenerationOutcome) -> Value {
match outcome {
GenerationOutcome::Created {
series_id,
key,
date,
issue_number,
filename,
} => json!({
"status": "created",
"series_id": series_id.as_str(),
"occurrence_key": key.as_str(),
"date": date.as_str(),
"issue_number": issue_number,
"filename": filename,
}),
GenerationOutcome::NoOp {
series_id,
key,
state,
message,
} => json!({
"status": "no-op",
"series_id": series_id.as_str(),
"occurrence_key": key.as_str(),
"key_state": key_state_str(*state),
"message": message,
}),
GenerationOutcome::Rejected { diagnostics } => json!({
"status": "rejected",
"diagnostics": diagnostics
.iter()
.map(|diagnostic| {
json!({
"kind": diagnostic.kind.as_str(),
"message": diagnostic.message,
})
})
.collect::<Vec<_>>(),
}),
}
}
pub(super) fn key_state_str(state: KeyState) -> &'static str {
match state {
KeyState::Generated => "generated",
KeyState::Skipped => "skipped",
KeyState::Free => "free",
}
}
pub(super) fn lifecycle_text(series_id: &SeriesId, outcome: &LifecycleOutcome) -> String {
match outcome {
LifecycleOutcome::Applied {
action,
state,
detail,
..
} => format!(
"{}: series {series_id} is now {}\n {detail}\n",
action.as_str(),
state.as_str()
),
LifecycleOutcome::NoOp {
action, message, ..
} => format!("{}: no-op: {message}\n", action.as_str()),
LifecycleOutcome::Rejected { .. } => outcome.render(),
}
}
pub(super) fn lifecycle_value(outcome: &LifecycleOutcome) -> Value {
match outcome {
LifecycleOutcome::Applied {
series_id,
action,
state,
key,
detail,
} => json!({
"status": "applied",
"series_id": series_id.as_str(),
"action": action.as_str(),
"state": state.as_str(),
"occurrence_key": key.as_ref().map(|key| key.as_str()),
"history": {
"kind": crate::model::HistoryEventKind::TransitionAccepted.as_str(),
"detail": detail,
},
}),
LifecycleOutcome::NoOp {
series_id,
action,
message,
} => json!({
"status": "no-op",
"series_id": series_id.as_str(),
"action": action.as_str(),
"message": message,
}),
LifecycleOutcome::Rejected { diagnostics } => json!({
"status": "rejected",
"diagnostics": diagnostics
.iter()
.map(lifecycle_diagnostic_value)
.collect::<Vec<_>>(),
}),
}
}
pub(super) fn lifecycle_diagnostic_value(diagnostic: &LifecycleDiagnostic) -> Value {
json!({
"kind": diagnostic.kind.as_str(),
"message": diagnostic.message,
})
}
pub(super) fn rejected_result(text: String, structured: Value) -> Value {
json!({
"content": [{ "type": "text", "text": text }],
"structuredContent": structured,
"isError": true,
})
}
pub(super) fn recurring_confirmed(args: &Value) -> std::result::Result<bool, ()> {
Ok(opt_bool(args, "confirm")?.unwrap_or(false))
}
pub(super) fn recurring_actor(args: &Value) -> std::result::Result<String, ()> {
Ok(opt_string(args, "actor")?.unwrap_or_else(cmd_recurring::actor))
}
pub(super) fn lifecycle_tool(store: &Store, args: &Value, action: LifecycleAction) -> ToolOutcome {
let raw = req_string(args, "series")?;
let confirmed = recurring_confirmed(args)?;
let date = match (action.needs_key(), opt_string(args, "date")?) {
(true, Some(date)) => date,
(true, None) => {
return Ok(error_result(format!(
"`{}` is about one occurrence: `date` (`YYYY-MM-DD`) is required, because \
the key that records the decision is `<series-id>@<date>`",
action.as_str()
)));
}
(false, Some(_)) => {
return Ok(error_result(format!(
"`{}` is a series-level action and takes no `date`: it is not about one \
occurrence",
action.as_str()
)));
}
(false, None) => String::new(),
};
let actor = recurring_actor(args)?;
let index = match recurring_index(store) {
Ok(index) => index,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let series = match cmd_recurring::resolve(&index, &raw) {
Ok(series) => series,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let mut request = LifecycleRequest::new(series.id.clone(), action, actor);
if action.needs_key() {
let context = match cmd_recurring::context_for(Some(&date), None) {
Ok(context) => context,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
request = request.about(OccurrenceKey::new(
&series.id,
&DateKey::from_date(context.today),
));
}
if let Some(reason) = opt_string(args, "reason")? {
request = request.because(reason);
}
let request = if confirmed {
request.confirm()
} else {
request
};
match apply_lifecycle(store, &request) {
Ok(outcome @ LifecycleOutcome::Rejected { .. }) => Ok(rejected_result(
crate::error::render_error(&crate::error::TixError::Refused(outcome.render())),
lifecycle_value(&outcome),
)),
Ok(outcome) => Ok(success_result(
lifecycle_text(&series.id, &outcome),
lifecycle_value(&outcome),
)),
Err(error) => Ok(error_result(crate::error::render_error(&error))),
}
}