#[allow(clippy::wildcard_imports)]
use super::*;
pub(super) fn cycle_schedule_create(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if !opt_bool(args, "confirm")?.unwrap_or(false) {
return Ok(cycle_schedule_unconfirmed("create"));
}
let draft = ScheduleDraft {
id: req_string(args, "id")?,
name: req_string(args, "name")?,
cadence: req_string(args, "cadence")?,
interval: opt_u32(args, "interval")?,
unit: opt_string(args, "unit")?,
duration: req_u32(args, "duration")?,
duration_unit: req_string(args, "duration_unit")?,
start_date: req_string(args, "start_date")?,
timezone: req_string(args, "timezone")?,
look_ahead: opt_u32(args, "look_ahead")?,
};
let (mut config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let id = match registry::create(&mut config, &root, &draft) {
Ok(id) => id,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let Some(record) = index.schedule(&id) else {
return Ok(cycle_schedule_failure(&crate::error::TixError::NotFound(
format!("cycle schedule `{id}`"),
)));
};
Ok(success_result(
format!("created schedule `{id}`\n"),
cycle_schedule_value(&id, record, &index),
))
}
pub(super) fn cycle_schedule_delete(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if !opt_bool(args, "confirm")?.unwrap_or(false) {
return Ok(cycle_schedule_unconfirmed("delete"));
}
let selector = req_string(args, "schedule")?;
let (mut config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let id = match registry::resolve(&index, &selector) {
Ok(id) => id,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let Some(record) = index.schedule(&id).cloned() else {
return Ok(cycle_schedule_failure(&crate::error::TixError::NotFound(
format!("cycle schedule `{selector}`"),
)));
};
let deleted = cycle_schedule_value(&id, &record, &index);
let deletion = match registry::delete(&mut config, &root, &selector) {
Ok(deletion) => deletion,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let generated = deletion.generated_cycles;
let mut text = format!("deleted schedule `{id}`\n");
if generated > 0 {
text.push_str(&format!(
"note: {generated} generated cycle(s) and their snapshots were left unchanged; they \
now reference a missing schedule\n"
));
}
Ok(success_result(
text,
json!({
"deleted": deleted,
"generated": generated,
}),
))
}
pub(super) fn cycle_schedule_detect(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let date = opt_string(args, "date")?;
let timezone = opt_string(args, "timezone")?;
let (config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let context = match cmd_recurring::context_for(date.as_deref(), timezone.as_deref()) {
Ok(context) => context,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let evaluation = evaluate_cycle_schedules(&index, &context);
Ok(success_result(
cycle_detection_text(&evaluation),
json!({
"date": context.today.to_string(),
"timezone": context.timezone.as_ref().map(ToString::to_string),
"schedules_examined": evaluation.schedules_examined(),
"has_failures": evaluation.has_failures(),
"warnings": evaluation
.warnings()
.iter()
.map(cycle_warning_value)
.collect::<Vec<_>>(),
}),
))
}
pub(super) fn cycle_schedule_edit(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if !opt_bool(args, "confirm")?.unwrap_or(false) {
return Ok(cycle_schedule_unconfirmed("edit"));
}
let selector = req_string(args, "schedule")?;
let patch = ScheduleEdit {
name: opt_string(args, "name")?,
cadence: opt_string(args, "cadence")?,
interval: opt_u32(args, "interval")?,
unit: opt_string(args, "unit")?,
duration: opt_u32(args, "duration")?,
duration_unit: opt_string(args, "duration_unit")?,
start_date: opt_string(args, "start_date")?,
timezone: opt_string(args, "timezone")?,
look_ahead: opt_u32(args, "look_ahead")?,
};
let (mut config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let id = match registry::edit(&mut config, &root, &selector, &patch) {
Ok(id) => id,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let Some(record) = index.schedule(&id) else {
return Ok(cycle_schedule_failure(&crate::error::TixError::NotFound(
format!("cycle schedule `{id}`"),
)));
};
Ok(success_result(
format!("updated schedule `{id}`\n"),
cycle_schedule_value(&id, record, &index),
))
}
pub(super) fn cycle_schedule_generate(
_config: &Config,
store: &Store,
args: &Value,
) -> ToolOutcome {
if !opt_bool(args, "confirm")?.unwrap_or(false) {
return Ok(cycle_schedule_unconfirmed("generate"));
}
let selector = req_string(args, "schedule")?;
let through = opt_string(args, "through")?;
let date = opt_string(args, "date")?;
let timezone = opt_string(args, "timezone")?;
let actor = cycle_schedule_actor(args)?;
let (mut config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let id = match registry::resolve(&index, &selector) {
Ok(id) => id,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let look_ahead = match through.as_deref() {
Some(through) => match cycle_candidates_through(&index, &id, through) {
Ok(count) => count,
Err(error) => return Ok(cycle_schedule_failure(&error)),
},
None => index.schedule(&id).map_or(0, |record| record.look_ahead),
};
let context = match cmd_recurring::context_for(date.as_deref(), timezone.as_deref()) {
Ok(context) => context,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let request = CycleGenerationRequest::new(id, context, look_ahead, actor).confirm();
let report = match generate_cycles(&mut config, &root, &index, &request) {
Ok(report) => report,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let value = cycle_report_value(&report);
let (text, failures) = cycle_generation_text(&report);
if !failures.is_empty() {
return Ok(rejected_result(
crate::error::render_error(&crate::error::TixError::Refused(failures.join("\n"))),
value,
));
}
Ok(success_result(text, value))
}
pub(super) fn cycle_schedule_list(_config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
let (config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let mut text = String::from(
"id\tname\tstate\tcadence\tduration\tstart_date\tlook_ahead\tnext_sequence\tgenerated\n",
);
for (id, record) in index.schedules() {
text.push_str(&cycle_schedule_row(id, record, &index));
}
let (_, diagnostics) = cycle_schedule_diagnostics(&index, None);
Ok(success_result(
text,
json!({
"schedules": index
.schedules()
.map(|(id, record)| cycle_schedule_value(id, record, &index))
.collect::<Vec<_>>(),
"schedule_count": index.schedule_count(),
"generated_count": index.generated_count(),
"diagnostics": diagnostics,
}),
))
}
pub(super) fn cycle_schedule_show(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let selector = req_string(args, "schedule")?;
let (config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let id = match registry::resolve(&index, &selector) {
Ok(id) => id,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let Some(record) = index.schedule(&id) else {
return Ok(cycle_schedule_failure(&crate::error::TixError::NotFound(
format!("cycle schedule `{selector}`"),
)));
};
let (_, diagnostics) = cycle_schedule_diagnostics(&index, Some(&id));
Ok(success_result(
cycle_schedule_detail(&id, record, &index),
json!({
"schedule": cycle_schedule_value(&id, record, &index),
"cursor": cycle_cursor_value(&index.cursor(&id)),
"instances": index
.cycles_of(&id)
.map(cycle_instance_value)
.collect::<Vec<_>>(),
"diagnostics": diagnostics,
}),
))
}
pub(super) fn cycle_snapshot_list(_config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
let (_, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let snapshots = match CycleSnapshotStore::new(&root).list() {
Ok(snapshots) => snapshots,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let mut text = String::from(
"cycle_id\tschedule_id\tsequence\tstart_date\tdue_date\tclosed_at\tclosed_by\tstate\n",
);
for snapshot in &snapshots {
text.push_str(&format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
snapshot.cycle_id,
snapshot.schedule_id,
snapshot.sequence,
snapshot.start_date,
snapshot.due_date,
snapshot.closed_at,
snapshot.closed_by,
snapshot.state_summary
));
}
Ok(success_result(
text,
json!({
"directory": "docs/cycle_snapshots",
"snapshot_count": snapshots.len(),
"snapshots": snapshots.iter().map(cycle_snapshot_value).collect::<Vec<_>>(),
}),
))
}
pub(super) fn dispatch(
config: &Config,
store: &Store,
name: &str,
arguments: &Value,
) -> Option<ToolOutcome> {
match name {
"cycle_schedule_list" => Some(cycle_schedule_list(config, store, arguments)),
"cycle_schedule_show" => Some(cycle_schedule_show(config, store, arguments)),
"cycle_schedule_create" => Some(cycle_schedule_create(config, store, arguments)),
"cycle_schedule_edit" => Some(cycle_schedule_edit(config, store, arguments)),
"cycle_schedule_delete" => Some(cycle_schedule_delete(config, store, arguments)),
"cycle_schedule_detect" => Some(cycle_schedule_detect(config, store, arguments)),
"cycle_schedule_generate" => Some(cycle_schedule_generate(config, store, arguments)),
"cycle_schedule_close" => Some(cycle_schedule_lifecycle(
store,
arguments,
CycleLifecycleAction::Close,
)),
"cycle_schedule_reopen" => Some(cycle_schedule_lifecycle(
store,
arguments,
CycleLifecycleAction::Reopen,
)),
"cycle_schedule_extend" => Some(cycle_schedule_lifecycle(
store,
arguments,
CycleLifecycleAction::Extend,
)),
"cycle_schedule_skip" => Some(cycle_schedule_lifecycle(
store,
arguments,
CycleLifecycleAction::Skip,
)),
"cycle_schedule_pause" => Some(cycle_schedule_lifecycle(
store,
arguments,
CycleLifecycleAction::Pause,
)),
"cycle_schedule_resume" => Some(cycle_schedule_lifecycle(
store,
arguments,
CycleLifecycleAction::Resume,
)),
"cycle_schedule_adjust_future" => Some(cycle_schedule_lifecycle(
store,
arguments,
CycleLifecycleAction::AdjustFuture,
)),
"cycle_schedule_ignore" => Some(cycle_schedule_lifecycle(
store,
arguments,
CycleLifecycleAction::Ignore,
)),
"cycle_snapshot_list" => Some(cycle_snapshot_list(config, store, arguments)),
_ => None,
}
}
pub(super) fn cycle_schedule_config(
store: &Store,
) -> TixResult<(Config, std::path::PathBuf, std::path::PathBuf)> {
let (config, path) = registry_config(store)?;
let root = path
.parent()
.map_or_else(|| std::path::PathBuf::from("."), Path::to_path_buf);
Ok((config, root, path))
}
pub(super) fn cycle_schedule_index(config: &Config, root: &Path) -> TixResult<ScheduleIndex> {
Ok(ScheduleIndex::build(config, &CycleStore::new(root).list()?))
}
pub(super) fn cycle_schedule_failure(error: &crate::error::TixError) -> Value {
error_result(crate::error::render_error(error))
}
pub(super) fn cycle_schedule_unconfirmed(action: &str) -> Value {
cycle_schedule_failure(®istry::refuse(
"not_confirmed",
format!(
"`tix cycle schedule {action}` changes state; pass `--confirm` to proceed — nothing \
was written"
),
))
}
pub(super) fn cycle_candidates_through(
index: &ScheduleIndex,
id: &CycleScheduleId,
through: &str,
) -> TixResult<u32> {
let through = registry::canonical_date(through)?;
let Some(first) = index.next_key(id) else {
return Ok(0);
};
let Some(record) = index.schedule(id) else {
return Ok(0);
};
let Some(rule) = cadence_rule(record) else {
return Ok(0);
};
let mut count: u32 = 0;
let mut date = first.start_date.clone();
while let Some(start) = date {
if start.as_str() > through.as_str() {
break;
}
count = count.saturating_add(1);
if count == u32::MAX {
break;
}
let Ok(context) = cmd_recurring::context_for(Some(start.as_str()), None) else {
break;
};
match rule.next_occurrence(&context) {
Ok(next) => date = Some(next.as_str().to_string()),
Err(_) => break,
}
}
Ok(count)
}
pub(super) fn cycle_schedule_actor(args: &Value) -> std::result::Result<String, ()> {
Ok(opt_string(args, "actor")?.unwrap_or_else(cmd_recurring::actor))
}
pub(super) fn cycle_cadence_summary(record: &CycleScheduleConfiguration) -> String {
cadence_rule(record)
.map(|rule| rule.summary())
.unwrap_or_else(|| "(no cadence)".to_string())
}
pub(super) fn cycle_duration_text(record: &CycleScheduleConfiguration) -> String {
match record.duration.unit {
Some(unit) => format!("{} {}", record.duration.count, unit.as_str()),
None => format!("{} (no unit)", record.duration.count),
}
}
pub(super) fn cycle_cursor_value(cursor: &GenerationCursor) -> Value {
json!({
"next_sequence": cursor.next_sequence,
"next_start": cursor.next_start,
"last_generated_key": cursor
.last_generated_key
.as_ref()
.map(GeneratedCycleId::as_str),
})
}
pub(super) fn cycle_instance_value(cycle: &GeneratedCycle) -> Value {
json!({
"id": cycle.id.as_str(),
"schedule_id": cycle.schedule_id.as_str(),
"sequence": cycle.sequence,
"name": cycle.name,
"state": cycle.state.as_str(),
"start_date": cycle.start_date,
"due_date": cycle.due_date,
})
}
pub(super) fn cycle_schedule_value(
id: &CycleScheduleId,
record: &CycleScheduleConfiguration,
index: &ScheduleIndex,
) -> Value {
let cursor = index.cursor(id);
json!({
"id": id.as_str(),
"name": record.name,
"state": record.state.as_str(),
"cadence": cycle_cadence_summary(record),
"cadence_kind": record.cadence.kind.map(RuleKind::as_str),
"interval": record.cadence.interval,
"unit": record.cadence.unit.map(IntervalUnit::as_str),
"duration": {
"count": record.duration.count,
"unit": record.duration.unit.map(IntervalUnit::as_str),
},
"start_date": record.start_date,
"timezone": record.timezone.as_ref().map(ToString::to_string),
"look_ahead": record.look_ahead,
"next_sequence": cursor.next_sequence,
"next_start": cursor.next_start,
"last_generated_key": cursor
.last_generated_key
.as_ref()
.map(GeneratedCycleId::as_str),
"generated": index.cycles_of(id).count(),
})
}
pub(super) fn cycle_schedule_row(
id: &CycleScheduleId,
record: &CycleScheduleConfiguration,
index: &ScheduleIndex,
) -> String {
let cursor = index.cursor(id);
let next = cursor
.next_sequence
.map(|sequence| sequence.to_string())
.unwrap_or_else(|| "-".to_string());
format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
id,
record.name,
record.state.as_str(),
cycle_cadence_summary(record),
cycle_duration_text(record),
record.start_date,
record.look_ahead,
next,
index.cycles_of(id).count()
)
}
pub(super) fn cycle_schedule_detail(
id: &CycleScheduleId,
record: &CycleScheduleConfiguration,
index: &ScheduleIndex,
) -> String {
let cursor = index.cursor(id);
let last = cursor
.last_generated_key
.as_ref()
.map(GeneratedCycleId::as_str)
.unwrap_or("-");
let next = cursor
.next_sequence
.map(|sequence| sequence.to_string())
.unwrap_or_else(|| "-".to_string());
let mut text = format!(
"id: {id}\nname: {}\nstate: {}\ncadence: {}\nduration: {}\nstart_date: {}\ntimezone: \
{}\nlook_ahead: {}\nnext_sequence: {next}\nnext_start: {}\nlast_generated_key: \
{last}\ngenerated: {}\n",
record.name,
record.state.as_str(),
cycle_cadence_summary(record),
cycle_duration_text(record),
record.start_date,
record
.timezone
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| "(none)".to_string()),
record.look_ahead,
cursor.next_start.as_deref().unwrap_or("-"),
index.cycles_of(id).count()
);
for cycle in index.cycles_of(id) {
text.push_str(&format!(
" {}\t{}\t{} -> {}\n",
cycle.id,
cycle.state.as_str(),
cycle.start_date,
cycle.due_date
));
}
text
}
pub(super) fn cycle_schedule_diagnostics(
index: &ScheduleIndex,
only: Option<&CycleScheduleId>,
) -> (String, Value) {
let wanted: Vec<&crate::cycle_schedule::identity::IdentityDiagnostic> = index
.diagnostics()
.iter()
.filter(|diagnostic| match only {
Some(id) => diagnostic.schedule_id.as_ref() == Some(id),
None => true,
})
.collect();
let mut text = String::new();
for diagnostic in &wanted {
text.push_str(&diagnostic.render());
text.push('\n');
}
let values: Vec<Value> = wanted
.iter()
.map(|diagnostic| {
json!({
"kind": diagnostic.kind.as_str(),
"schedule_id": diagnostic.schedule_id.as_ref().map(CycleScheduleId::as_str),
"cycle_id": diagnostic.cycle_id.as_ref().map(GeneratedCycleId::as_str),
"field": diagnostic.field,
"message": diagnostic.message,
})
})
.collect();
(text, Value::Array(values))
}
pub(super) fn cycle_warning_value(
warning: &crate::cycle_schedule::evaluate::ScheduleWarning,
) -> Value {
json!({
"kind": warning.kind.as_str(),
"schedule_id": warning.schedule_id.as_str(),
"cycle_id": warning.cycle_id.as_ref().map(GeneratedCycleId::as_str),
"effective_dates": warning.effective_dates.as_ref().map(|dates| json!({
"start": dates.start,
"due": dates.due,
})),
"next_generation": warning.next_generation.as_ref().map(|key| json!({
"id": key.id.as_str(),
"sequence": key.sequence,
"start_date": key.start_date,
})),
"available_actions": warning
.available_actions
.iter()
.map(|action| action.as_str())
.collect::<Vec<_>>(),
"diagnostic": warning.diagnostic.as_ref().map(|diagnostic| json!({
"kind": diagnostic.kind.as_str(),
"field": diagnostic.field,
"message": diagnostic.message,
})),
})
}
pub(super) fn cycle_detection_text(
evaluation: &crate::cycle_schedule::evaluate::CycleEvaluation,
) -> String {
if evaluation.is_empty() {
return format!(
"No cycle-schedule warnings across {} schedule(s).\n",
evaluation.schedules_examined()
);
}
let mut text = String::from(
"schedule_id\tkind\tcycle_id\tstart\tdue\tnext_start\tnext_id\tactions\tdetail\n",
);
for warning in evaluation.warnings() {
let start = warning
.effective_dates
.as_ref()
.map(|dates| dates.start.as_str())
.unwrap_or("-");
let due = warning
.effective_dates
.as_ref()
.map(|dates| dates.due.as_str())
.unwrap_or("-");
let next_start = warning
.next_generation
.as_ref()
.and_then(|key| key.start_date.as_deref())
.unwrap_or("-");
let next_id = warning
.next_generation
.as_ref()
.map(|key| key.id.as_str())
.unwrap_or("-");
let actions = if warning.available_actions.is_empty() {
"-".to_string()
} else {
warning
.available_actions
.iter()
.map(|action| action.as_str())
.collect::<Vec<_>>()
.join(",")
};
let detail = warning
.diagnostic
.as_ref()
.map(|diagnostic| diagnostic.message.as_str())
.unwrap_or("-");
let cycle_id = warning
.cycle_id
.as_ref()
.map(GeneratedCycleId::as_str)
.unwrap_or("-");
text.push_str(&format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
warning.schedule_id,
warning.kind.as_str(),
cycle_id,
start,
due,
next_start,
next_id,
actions,
detail
));
}
text
}
pub(super) fn cycle_generation_outcome_value(outcome: &CycleGenerationOutcome) -> Value {
match outcome {
CycleGenerationOutcome::Created { key, cycle, .. } => json!({
"status": "created",
"key": key.as_str(),
"cycle": cycle_instance_value(cycle),
}),
CycleGenerationOutcome::NoOp {
key,
state,
message,
..
} => json!({
"status": "no-op",
"key": key.as_str(),
"state": state.as_str(),
"message": message,
}),
CycleGenerationOutcome::Rejected {
key, diagnostics, ..
} => json!({
"status": "rejected",
"key": key.as_ref().map(GeneratedCycleId::as_str),
"diagnostics": diagnostics
.iter()
.map(|diagnostic| json!({
"kind": diagnostic.kind.as_str(),
"field": diagnostic.field,
"message": diagnostic.message,
}))
.collect::<Vec<_>>(),
}),
}
}
pub(super) fn cycle_report_value(report: &CycleGenerationReport) -> Value {
json!({
"actor": report.actor(),
"as_of": report.as_of().to_string(),
"schedule_id": report.schedule_id().as_str(),
"outcomes": report
.outcomes()
.iter()
.map(cycle_generation_outcome_value)
.collect::<Vec<_>>(),
"created": report.created().count(),
"no_ops": report.no_ops().count(),
"rejected": report.rejected().count(),
"refused": report.is_refused(),
"cursor": report.cursor().map(cycle_cursor_value),
})
}
pub(super) fn cycle_generation_text(report: &CycleGenerationReport) -> (String, Vec<String>) {
let mut text = String::new();
let mut failures = Vec::new();
for outcome in report.outcomes() {
if outcome.is_rejected() {
failures.push(outcome.render());
} else {
text.push_str(&outcome.render());
text.push('\n');
}
}
if report.is_empty() {
text.push_str(&report.render());
text.push('\n');
}
(text, failures)
}
pub(super) fn cycle_lifecycle_value(outcome: &CycleLifecycleOutcome) -> Value {
match outcome {
CycleLifecycleOutcome::Applied {
action,
schedule_id,
cycle_id,
snapshot_path,
dates,
message,
} => json!({
"status": "applied",
"action": action.as_str(),
"schedule_id": schedule_id.as_str(),
"cycle_id": cycle_id.as_ref().map(GeneratedCycleId::as_str),
"snapshot_path": snapshot_path,
"dates": dates
.as_ref()
.map(|(start, due)| json!({ "start": start, "due": due })),
"message": message,
}),
CycleLifecycleOutcome::NoOp {
action,
schedule_id,
cycle_id,
message,
} => json!({
"status": "no-op",
"action": action.as_str(),
"schedule_id": schedule_id.as_str(),
"cycle_id": cycle_id.as_ref().map(GeneratedCycleId::as_str),
"message": message,
}),
CycleLifecycleOutcome::Rejected {
action,
schedule_id,
cycle_id,
diagnostics,
} => json!({
"status": "rejected",
"action": action.as_str(),
"schedule_id": schedule_id.as_str(),
"cycle_id": cycle_id.as_ref().map(GeneratedCycleId::as_str),
"diagnostics": diagnostics
.iter()
.map(|diagnostic| json!({
"kind": diagnostic.kind.as_str(),
"field": diagnostic.field,
"message": diagnostic.message,
}))
.collect::<Vec<_>>(),
}),
}
}
pub(super) fn yaml_map_value(
metadata: &std::collections::BTreeMap<String, serde_yaml::Value>,
) -> Value {
let mut map = serde_json::Map::with_capacity(metadata.len());
for (key, value) in metadata {
map.insert(
key.clone(),
serde_json::to_value(value).unwrap_or(Value::Null),
);
}
Value::Object(map)
}
pub(super) fn cycle_snapshot_value(snapshot: &CycleSnapshot) -> Value {
json!({
"cycle_id": snapshot.cycle_id.as_str(),
"schedule_id": snapshot.schedule_id.as_str(),
"sequence": snapshot.sequence,
"start_date": snapshot.start_date,
"due_date": snapshot.due_date,
"closed_at": snapshot.closed_at,
"closed_by": snapshot.closed_by,
"state_summary": snapshot.state_summary,
"measurements": yaml_map_value(&snapshot.measurements),
"extensions": yaml_map_value(&snapshot.extensions),
})
}
pub(super) fn cycle_schedule_target(
index: &ScheduleIndex,
id: &CycleScheduleId,
cycle: Option<&str>,
from: Option<&str>,
) -> TixResult<Option<GeneratedCycleId>> {
if let Some(raw) = cycle {
return Ok(Some(GeneratedCycleId::parse(raw)?));
}
let Some(raw) = from else {
return Ok(None);
};
let wanted = registry::canonical_date(raw)?;
match index
.cycles_of(id)
.find(|cycle| cycle.start_date == wanted)
.map(|cycle| cycle.id.clone())
{
Some(cycle_id) => Ok(Some(cycle_id)),
None => Err(registry::refuse(
"unknown_cycle",
format!(
"no instance of schedule `{id}` starts on `{wanted}`; pass `--cycle \
<schedule-id>:<sequence>` to name one"
),
)),
}
}
pub(super) fn cycle_schedule_lifecycle(
store: &Store,
args: &Value,
action: CycleLifecycleAction,
) -> ToolOutcome {
let selector = req_string(args, "schedule")?;
if !opt_bool(args, "confirm")?.unwrap_or(false) {
return Ok(cycle_schedule_unconfirmed(action.as_str()));
}
if let Some(periods) = opt_u32(args, "by")?
&& periods != 1
{
return Ok(cycle_schedule_failure(®istry::refuse(
"invalid_input",
format!(
"`--by {periods}` is not supported: the shared lifecycle extends an instance by \
exactly one cadence period per action, and this surface will not re-derive a \
multi-period step of its own. Extend once per period."
),
)));
}
let reason = opt_string(args, "reason")?;
let cycle = opt_string(args, "cycle")?;
let from = opt_string(args, "from")?;
let actor = cycle_schedule_actor(args)?;
let timestamp = opt_string(args, "timestamp")?.unwrap_or_else(crate::model::utc_now_rfc3339);
let (mut config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let id = match registry::resolve(&index, &selector) {
Ok(id) => id,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let cycle_id = match cycle_schedule_target(&index, &id, cycle.as_deref(), from.as_deref()) {
Ok(cycle_id) => cycle_id,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let mut request = CycleLifecycleRequest::new(action, id, actor).with_timestamp(timestamp);
if let Some(cycle_id) = cycle_id {
request = request.for_cycle(cycle_id);
}
if let Some(reason) = reason {
request = request.with_reason(reason);
}
let request =
match crate::commands::capacity::with_close_capacity(request, &config, &root, store) {
Ok(request) => request,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let outcome = match apply_cycle_lifecycle(&mut config, &root, &request.confirm()) {
Ok(outcome) => outcome,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let value = cycle_lifecycle_value(&outcome);
if outcome.is_rejected() {
return Ok(rejected_result(
crate::error::render_error(&crate::error::TixError::Refused(outcome.render())),
value,
));
}
Ok(success_result(format!("{}\n", outcome.render()), value))
}