use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use crate::cli::{
CycleCommand, CycleScheduleCommand, CycleScheduleCreateArgs, CycleScheduleEditArgs,
CycleScheduleLifecycleArgs,
};
use crate::config::Config;
use crate::cycle_schedule::config::CycleScheduleId;
use crate::cycle_schedule::evaluate::evaluate;
use crate::cycle_schedule::generate::{GenerationRequest, generate};
use crate::cycle_schedule::identity::{GeneratedCycleId, ScheduleIndex, cadence_rule};
use crate::cycle_schedule::lifecycle::{
LifecycleAction, LifecycleOutcome, LifecycleRequest, apply,
};
use crate::cycle_schedule::registry::{self, ScheduleDraft, ScheduleEdit};
use crate::error::{Result, TixError};
use crate::planning::Planner;
use crate::recurrence::rule::IntervalUnit;
use crate::storage::Store;
fn root_from_store(store: &Store) -> PathBuf {
Config::project_root(&store.issues_dir).unwrap_or_else(|| PathBuf::from("."))
}
pub fn run_legacy(config: &Config, store: &Store, cmd: &CycleCommand) -> Result<()> {
let mut planner = Planner::from_config(config.clone(), root_from_store(store));
match cmd {
CycleCommand::Create {
name,
description,
start_date,
due_date,
} => create(
&mut planner,
name.as_deref(),
description.as_deref(),
start_date.as_deref(),
due_date.as_deref(),
),
CycleCommand::List => list(&planner, store),
CycleCommand::Edit {
name,
new_name,
description,
start_date,
due_date,
} => edit(
&mut planner,
store,
name,
new_name.as_deref(),
description.as_deref(),
start_date.as_deref(),
due_date.as_deref(),
),
CycleCommand::Delete { name, yes } => delete(&mut planner, store, name, *yes),
CycleCommand::Schedule { .. } => {
unreachable!("`tix cycle schedule` is routed by `cli::run`, not by `cycle::run_legacy`")
}
}
}
fn create(
planner: &mut Planner,
name: Option<&str>,
description: Option<&str>,
start_date: Option<&str>,
due_date: Option<&str>,
) -> Result<()> {
let name = planner.add_cycle(name.map(str::to_string))?;
planner.set_cycle_fields(&name, description, start_date, due_date)?;
let overlaps = planner.overlapping_cycles(&name);
println!("{name}");
if !overlaps.is_empty() {
eprintln!(
"{}",
crate::diagnostics::Diagnostic::warning(
"W-tix-cycle",
format!(
"cycle `{name}` overlaps existing cycle(s): {}",
overlaps.join(", ")
)
)
.render_auto()
);
}
Ok(())
}
fn list(planner: &Planner, store: &Store) -> Result<()> {
for name in planner.cycle_names() {
let Some(cycle) = planner.cycle(&name) else {
continue;
};
let issues = store
.list()?
.into_iter()
.filter(|issue| {
issue
.cycles
.as_ref()
.is_some_and(|cycles| cycles.iter().any(|cycle_name| cycle_name == &name))
})
.count();
let description = cycle.description.as_deref().unwrap_or("");
let start = cycle.start_date.as_deref().unwrap_or("");
let due = cycle.due_date.as_deref().unwrap_or("");
println!("{name}\t{description}\t{start}\t{due}\t{issues}");
}
Ok(())
}
fn edit(
planner: &mut Planner,
store: &Store,
name: &str,
new_name: Option<&str>,
description: Option<&str>,
start_date: Option<&str>,
due_date: Option<&str>,
) -> Result<()> {
let final_name = if let Some(new_name) = new_name {
let impact = planner.rename_cycle_cascade(store, name, new_name)?;
println!(
"renamed cycle `{name}` to `{new_name}`; updated {} issue(s)",
impact.changed_issue_count
);
new_name
} else {
name
};
if description.is_some() || start_date.is_some() || due_date.is_some() {
planner.set_cycle_fields(final_name, description, start_date, due_date)?;
}
Ok(())
}
fn delete(planner: &mut Planner, store: &Store, name: &str, yes: bool) -> Result<()> {
let impact = planner.delete_impact(store, name)?;
if !impact.referencing_issues.is_empty() {
eprintln!(
"cycle `{name}` is referenced by {} issue(s): {}",
impact.referencing_issues.len(),
impact.referencing_issues.join(", ")
);
}
let mut confirmed = yes || !std::io::stdin().is_terminal();
if !confirmed {
eprint!("Delete cycle '{name}'? [y/N] ");
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(TixError::Io)?;
confirmed = line.trim().eq_ignore_ascii_case("y");
}
if !confirmed {
eprintln!("aborted");
return Ok(());
}
planner.delete_cycle(name)?;
println!("deleted cycle `{name}`");
if !impact.referencing_issues.is_empty() {
println!("note: issue cycle references were left unchanged");
}
Ok(())
}
pub fn run_schedule(
config: &mut Config,
store: &Store,
command: &CycleScheduleCommand,
) -> Result<()> {
let root = root_from_store(store);
match command {
CycleScheduleCommand::List => schedule_list(config, &root),
CycleScheduleCommand::Show { schedule } => schedule_show(config, &root, schedule),
CycleScheduleCommand::Create(args) => schedule_create(config, &root, args),
CycleScheduleCommand::Edit {
schedule,
args,
confirm,
} => schedule_edit(config, &root, schedule, args, *confirm),
CycleScheduleCommand::Delete { schedule, confirm } => {
schedule_delete(config, &root, schedule, *confirm)
}
CycleScheduleCommand::Detect { date } => schedule_detect(config, &root, date.as_deref()),
CycleScheduleCommand::Generate {
schedule,
through,
confirm,
} => schedule_generate(config, &root, schedule, through.as_deref(), *confirm),
CycleScheduleCommand::Close { schedule, args } => lifecycle(
config,
store,
&root,
schedule,
LifecycleAction::Close,
&args.lifecycle,
args.reason.as_deref(),
None,
),
CycleScheduleCommand::Reopen { schedule, args } => lifecycle(
config,
store,
&root,
schedule,
LifecycleAction::Reopen,
&args.lifecycle,
args.reason.as_deref(),
None,
),
CycleScheduleCommand::Extend { schedule, args } => lifecycle(
config,
store,
&root,
schedule,
LifecycleAction::Extend,
&args.reason.lifecycle,
args.reason.reason.as_deref(),
args.by,
),
CycleScheduleCommand::Skip { schedule, args } => lifecycle(
config,
store,
&root,
schedule,
LifecycleAction::Skip,
&args.lifecycle,
args.reason.as_deref(),
None,
),
CycleScheduleCommand::Pause { schedule, args } => lifecycle(
config,
store,
&root,
schedule,
LifecycleAction::Pause,
args,
None,
None,
),
CycleScheduleCommand::Resume { schedule, args } => lifecycle(
config,
store,
&root,
schedule,
LifecycleAction::Resume,
args,
None,
None,
),
CycleScheduleCommand::AdjustFuture { schedule, args } => lifecycle(
config,
store,
&root,
schedule,
LifecycleAction::AdjustFuture,
&args.lifecycle,
args.reason.as_deref(),
None,
),
CycleScheduleCommand::Ignore { schedule, args } => lifecycle(
config,
store,
&root,
schedule,
LifecycleAction::Ignore,
args,
None,
None,
),
}
}
fn schedule_list(config: &Config, root: &Path) -> Result<()> {
let index = registry::load_index(config, root)?;
println!(
"id\tname\tstate\tcadence\tduration\tstart_date\tlook_ahead\tnext_sequence\tgenerated"
);
for (id, record) in index.schedules() {
let cadence = cadence_rule(record)
.map(|rule| rule.summary())
.unwrap_or_else(|| "(no cadence)".to_string());
let duration = match record.duration.unit {
Some(unit) => format!("{} {}", record.duration.count, unit.as_str()),
None => format!("{} (no unit)", record.duration.count),
};
let cursor = index.cursor(id);
let next = cursor
.next_sequence
.map(|sequence| sequence.to_string())
.unwrap_or_else(|| "-".to_string());
println!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
id,
record.name,
record.state.as_str(),
cadence,
duration,
record.start_date,
record.look_ahead,
next,
index.cycles_of(id).count()
);
}
Ok(())
}
fn schedule_show(config: &Config, root: &Path, selector: &str) -> Result<()> {
let index = registry::load_index(config, root)?;
let id = registry::resolve(&index, selector)?;
let record = index
.schedule(&id)
.expect("resolved schedule exists in the index");
println!("id: {id}");
println!("name: {}", record.name);
println!("state: {}", record.state.as_str());
println!(
"cadence: {}",
cadence_rule(record)
.map(|rule| rule.summary())
.unwrap_or_else(|| "(no cadence)".to_string())
);
println!(
"duration: {} {}",
record.duration.count,
record
.duration
.unit
.map_or("(no unit)", IntervalUnit::as_str)
);
println!("start_date: {}", record.start_date);
println!(
"timezone: {}",
record
.timezone
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| "(none)".to_string())
);
println!("look_ahead: {}", record.look_ahead);
let cursor = index.cursor(&id);
println!(
"next_sequence: {}",
cursor
.next_sequence
.map(|sequence| sequence.to_string())
.unwrap_or_else(|| "-".to_string())
);
println!(
"next_start: {}",
cursor.next_start.as_deref().unwrap_or("-")
);
println!(
"last_generated_key: {}",
cursor
.last_generated_key
.as_ref()
.map(GeneratedCycleId::as_str)
.unwrap_or("-")
);
println!("generated: {}", index.cycles_of(&id).count());
for cycle in index.cycles_of(&id) {
println!(
" {}\t{}\t{} -> {}",
cycle.id,
cycle.state.as_str(),
cycle.start_date,
cycle.due_date
);
}
for diagnostic in index
.diagnostics()
.iter()
.filter(|d| d.schedule_id.as_ref() == Some(&id))
{
eprintln!("{}", diagnostic.render());
}
Ok(())
}
fn schedule_create(config: &mut Config, root: &Path, args: &CycleScheduleCreateArgs) -> Result<()> {
require_confirm("create", args.confirm)?;
let id = registry::create(
config,
root,
&ScheduleDraft {
id: args.id.clone(),
name: args.name.clone(),
cadence: args.cadence.clone(),
interval: args.interval,
unit: args.unit.clone(),
duration: args.duration,
duration_unit: args.duration_unit.clone(),
start_date: args.start_date.clone(),
timezone: args.timezone.clone(),
look_ahead: args.look_ahead,
},
)?;
println!("created schedule `{id}`");
Ok(())
}
fn schedule_edit(
config: &mut Config,
root: &Path,
selector: &str,
args: &CycleScheduleEditArgs,
confirm: bool,
) -> Result<()> {
require_confirm("edit", confirm)?;
let id = registry::edit(
config,
root,
selector,
&ScheduleEdit {
name: args.name.clone(),
cadence: args.cadence.clone(),
interval: args.interval,
unit: args.unit.clone(),
duration: args.duration,
duration_unit: args.duration_unit.clone(),
start_date: args.start_date.clone(),
timezone: args.timezone.clone(),
look_ahead: args.look_ahead,
},
)?;
println!("updated schedule `{id}`");
Ok(())
}
fn schedule_delete(config: &mut Config, root: &Path, selector: &str, confirm: bool) -> Result<()> {
require_confirm("delete", confirm)?;
let deletion = registry::delete(config, root, selector)?;
let id = deletion.id;
println!("deleted schedule `{id}`");
if deletion.generated_cycles > 0 {
println!(
"note: {} generated cycle(s) and their snapshots were left unchanged; they now \
reference a missing schedule",
deletion.generated_cycles
);
}
Ok(())
}
fn schedule_detect(config: &Config, root: &Path, date: Option<&str>) -> Result<()> {
let context = crate::commands::recurring::context_for(date, None)?;
let index = registry::load_index(config, root)?;
let evaluation = evaluate(&index, &context);
if evaluation.is_empty() {
println!(
"No cycle-schedule warnings across {} schedule(s).",
evaluation.schedules_examined()
);
return Ok(());
}
println!("schedule_id\tkind\tcycle_id\tstart\tdue\tnext_start\tnext_id\tactions\tdetail");
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("-");
println!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
warning.schedule_id,
warning.kind.as_str(),
cycle_id,
start,
due,
next_start,
next_id,
actions,
detail
);
}
Ok(())
}
fn schedule_generate(
config: &mut Config,
root: &Path,
selector: &str,
through: Option<&str>,
confirm: bool,
) -> Result<()> {
require_confirm("generate", confirm)?;
let index = registry::load_index(config, root)?;
let id = registry::resolve(&index, selector)?;
let look_ahead = match through {
Some(through) => candidates_through(&index, &id, through)?,
None => index.schedule(&id).map_or(0, |record| record.look_ahead),
};
let context = crate::commands::recurring::context_for(None, None)?;
let request = GenerationRequest::new(id, context, look_ahead, actor()).confirm();
let report = generate(config, root, &index, &request)?;
let mut failures: Vec<String> = Vec::new();
for outcome in report.outcomes() {
if outcome.is_rejected() {
failures.push(outcome.render());
} else {
println!("{}", outcome.render());
}
}
if report.is_empty() {
println!("{}", report.render());
}
if !failures.is_empty() {
return Err(TixError::Refused(failures.join("\n")));
}
Ok(())
}
fn candidates_through(index: &ScheduleIndex, id: &CycleScheduleId, through: &str) -> Result<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) = crate::commands::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)
}
#[allow(clippy::too_many_arguments)]
fn lifecycle(
config: &mut Config,
store: &Store,
root: &Path,
selector: &str,
action: LifecycleAction,
args: &CycleScheduleLifecycleArgs,
reason: Option<&str>,
by: Option<u32>,
) -> Result<()> {
require_confirm(action.as_str(), args.confirm)?;
if let Some(periods) = by
&& periods != 1
{
return Err(registry::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 index = registry::load_index(config, root)?;
let id = registry::resolve(&index, selector)?;
let cycle_id = select_cycle(&index, &id, args.cycle.as_deref(), args.from.as_deref())?;
let mut request =
LifecycleRequest::new(action, id.clone(), actor()).with_timestamp(now_timestamp());
if let Some(cycle_id) = cycle_id.clone() {
request = request.for_cycle(cycle_id);
}
if let Some(reason) = reason {
request = request.with_reason(reason);
}
let request = crate::commands::capacity::with_close_capacity(request, config, root, store)?;
let request = request.confirm();
let outcome = apply(config, root, &request)?;
print_outcome(&outcome)
}
fn select_cycle(
index: &ScheduleIndex,
id: &CycleScheduleId,
cycle: Option<&str>,
from: Option<&str>,
) -> Result<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"
),
)),
}
}
fn print_outcome(outcome: &LifecycleOutcome) -> Result<()> {
if outcome.is_rejected() {
return Err(TixError::Refused(outcome.render()));
}
println!("{}", outcome.render());
Ok(())
}
fn actor() -> String {
crate::commands::recurring::actor()
}
fn now_timestamp() -> String {
crate::model::utc_now_rfc3339()
}
fn require_confirm(action: &str, confirm: bool) -> Result<()> {
if confirm {
return Ok(());
}
Err(crate::cycle_schedule::registry::refuse(
"not_confirmed",
format!(
"`tix cycle schedule {action}` changes state; pass `--confirm` to proceed — nothing \
was written"
),
))
}