#[allow(clippy::wildcard_imports)]
use super::*;
pub(super) fn time_add(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Some(refusal) = time_arguments(args, TIME_ADD_ARGS) {
return Ok(refusal);
}
let issue = selector(args, "issue")?;
let minutes = req_i64(args, "minutes")?;
let author = req_string(args, "author")?;
let mut entry = NewTimeEntry::new(minutes, author);
if let Some(date) = opt_string(args, "date")? {
entry = entry.on(date);
}
if let Some(note) = opt_string(args, "note")? {
entry = entry.with_note(note);
}
let request = time_request(args, TimeMutationRequest::create(issue, entry))?;
time_mutation(store, &request)
}
pub(super) fn time_delete(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Some(refusal) = time_arguments(args, TIME_DELETE_ARGS) {
return Ok(refusal);
}
if args.get("confirm").and_then(Value::as_bool) != Some(true) {
return Ok(time_input_refusal(
cmd_time::TIME_DELETE_CONFIRMATION.to_string(),
));
}
let issue = selector(args, "issue")?;
let entry_id = req_string(args, "entry")?;
let request = TimeMutationRequest::delete(
issue,
entry_id,
opt_string(args, "reason")?.unwrap_or_default(),
);
let request = time_request(args, request)?;
time_mutation(store, &request)
}
pub(super) fn time_edit(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Some(refusal) = time_arguments(args, TIME_EDIT_ARGS) {
return Ok(refusal);
}
let issue = selector(args, "issue")?;
let entry_id = req_string(args, "entry")?;
let named = |key: &str| args.get(key).is_some_and(|value| !value.is_null());
if named("date") && named("clear_date") {
return Ok(time_input_refusal(time_edit_conflict("date", "clear_date")));
}
if named("note") && named("clear_note") {
return Ok(time_input_refusal(time_edit_conflict("note", "clear_note")));
}
let mut change = TimeEntryEdit::new();
if let Some(minutes) = opt_i64(args, "minutes")? {
change = change.duration(minutes);
}
if let Some(date) = opt_string(args, "date")? {
change = change.date(date);
}
if opt_bool(args, "clear_date")?.unwrap_or(false) {
change = change.clear_date();
}
if let Some(note) = opt_string(args, "note")? {
change = change.note(note);
}
if opt_bool(args, "clear_note")?.unwrap_or(false) {
change = change.clear_note();
}
if let Some(author) = opt_string(args, "author")? {
change = change.author(author);
}
let request = TimeMutationRequest::edit(
issue,
entry_id,
change,
opt_string(args, "reason")?.unwrap_or_default(),
);
let request = time_request(args, request)?;
time_mutation(store, &request)
}
pub(super) fn time_history(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Some(refusal) = time_arguments(args, TIME_HISTORY_ARGS) {
return Ok(refusal);
}
let selector = selector(args, "issue")?;
let (number, snapshot) = match time_issue_snapshot(store, &selector) {
Ok(read) => read,
Err(error) => return Ok(time_refusal(&error)),
};
let audit = snapshot.audit(&TimeFilter::new().for_issue(number));
let current = audit.issues.first().map_or(0, |issue| issue.current);
let mut lines = vec![
format!("scope: {}", audit.filter.describe()),
format!("current: {current}"),
format!("deleted_count: {}", audit.deleted_count),
format!("corrected_count: {}", audit.corrected_count),
"entry\tdeleted_at\tdeleted_by".to_string(),
];
for issue in &audit.issues {
for deletion in &issue.deletions {
lines.push(format!(
"{}\t{}\t{}",
cmd_time::cell(deletion.entry_id.as_deref()),
cmd_time::cell(Some(deletion.deleted_at.as_str())),
cmd_time::cell(Some(deletion.deleted_by.as_str())),
));
}
}
let value = json!({
"scope": audit.filter.describe(),
"current": current,
"deleted_count": audit.deleted_count,
"corrected_count": audit.corrected_count,
"issues": audit.issues.iter().map(|issue| json!({
"issue": issue.issue,
"current": issue.current,
"corrections": issue.corrections,
"deletions": issue.deletions.iter().map(|deletion| json!({
"issue": deletion.issue,
"entry_id": deletion.entry_id,
"deleted_at": deletion.deleted_at,
"deleted_by": deletion.deleted_by,
})).collect::<Vec<Value>>(),
})).collect::<Vec<Value>>(),
"diagnostics": time_diagnostics_value(&audit.diagnostics),
});
Ok(success_result(format!("{}\n", lines.join("\n")), value))
}
pub(super) fn time_show(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Some(refusal) = time_arguments(args, TIME_SHOW_ARGS) {
return Ok(refusal);
}
let selector = selector(args, "issue")?;
let (number, snapshot) = match time_issue_snapshot(store, &selector) {
Ok(read) => read,
Err(error) => return Ok(time_refusal(&error)),
};
let filter = TimeFilter::new().for_issue(number);
let total = snapshot.total(&filter);
let (block, value) = match time_scope_payload(&total) {
Ok(payload) => payload,
Err(refusal) => return Ok(error_result(refusal)),
};
let mut lines = vec!["id\tminutes\tdate\tauthor\tnote".to_string()];
let mut entries = Vec::new();
for indexed in snapshot.entries() {
if !filter.selects(indexed.issue, &indexed.entry) {
continue;
}
let entry = &indexed.entry;
lines.push(format!(
"{}\t{}\t{}\t{}\t{}",
entry.id,
cmd_time::number(entry.minutes()),
cmd_time::cell(entry.date.as_deref()),
cmd_time::cell(Some(entry.author.as_str())),
cmd_time::cell(entry.note.as_deref()),
));
entries.push(time_entry_value(indexed.issue, entry));
}
lines.push(block);
let mut value = value;
value["entries"] = Value::Array(entries);
Ok(success_result(format!("{}\n", lines.join("\n")), value))
}
pub(super) fn time_total(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Some(refusal) = time_arguments(args, TIME_TOTAL_ARGS) {
return Ok(refusal);
}
let selected = match opt_selector(args, "issue")? {
Some(raw) => match store.resolve_issue(&raw) {
Ok(issue) if issue.archived => {
return Ok(time_refusal(&cmd_time::archived_refusal(&issue, &raw)));
}
Ok(issue) => Some(issue.number),
Err(error) => return Ok(time_refusal(&error)),
},
None => None,
};
let breakdown = match cmd_time::parse_breakdown(opt_string(args, "by")?.as_deref()) {
Ok(breakdown) => breakdown,
Err(error) => return Ok(time_refusal(&error)),
};
let mut filter = TimeFilter::new();
if let Some(number) = selected {
filter = filter.for_issue(number);
}
if let Some(assignee) = opt_string(args, "assignee")? {
filter = filter.for_assignee(assignee);
}
if let Some(from) = opt_string(args, "from")? {
filter = filter.since(from);
}
if let Some(to) = opt_string(args, "to")? {
filter = filter.until(to);
}
if opt_bool(args, "audit")?.unwrap_or(false) {
filter = filter.including_deleted();
}
let snapshot = match store.list() {
Ok(issues) => TimeSnapshot::of(&issues),
Err(error) => return Ok(time_refusal(&error)),
};
let scope = snapshot.total(&filter);
let (mut text, mut value) = match time_scope_payload(&scope) {
Ok(payload) => payload,
Err(refusal) => return Ok(error_result(refusal)),
};
if let Some(breakdown) = breakdown {
let groups = match breakdown {
cmd_time::Breakdown::Issue => snapshot.by_issue(&filter),
cmd_time::Breakdown::Assignee => snapshot.by_assignee(&filter),
cmd_time::Breakdown::Date => snapshot.by_date(&filter),
};
let mut values = Vec::with_capacity(groups.len());
for group in &groups {
let (block, group_value) = match time_scope_payload(group) {
Ok(payload) => payload,
Err(refusal) => return Ok(error_result(refusal)),
};
text.push_str("\n\n");
text.push_str(&block);
values.push(group_value);
}
value["groups"] = Value::Array(values);
}
Ok(success_result(format!("{}\n", text), value))
}
pub(super) fn dispatch(
config: &Config,
store: &Store,
name: &str,
arguments: &Value,
) -> Option<ToolOutcome> {
match name {
"time_show" => Some(time_show(config, store, arguments)),
"time_history" => Some(time_history(config, store, arguments)),
"time_total" => Some(time_total(config, store, arguments)),
"time_add" => Some(time_add(config, store, arguments)),
"time_edit" => Some(time_edit(config, store, arguments)),
"time_delete" => Some(time_delete(config, store, arguments)),
_ => None,
}
}
pub(super) const TIME_SHOW_ARGS: &[&str] = &["issue"];
pub(super) const TIME_HISTORY_ARGS: &[&str] = &["issue"];
pub(super) const TIME_TOTAL_ARGS: &[&str] = &["issue", "assignee", "from", "to", "audit", "by"];
pub(super) const TIME_ADD_ARGS: &[&str] = &[
"issue",
"minutes",
"date",
"author",
"note",
"actor",
"timestamp",
"reason",
];
pub(super) const TIME_EDIT_ARGS: &[&str] = &[
"issue",
"entry",
"minutes",
"date",
"clear_date",
"note",
"clear_note",
"author",
"reason",
"actor",
"timestamp",
];
pub(super) const TIME_DELETE_ARGS: &[&str] =
&["issue", "entry", "reason", "confirm", "actor", "timestamp"];
pub(super) fn time_edit_conflict(field: &str, clear: &str) -> String {
format!(
"a time edit names `{field}` once: pass either `{field}` (the replacement value) or \
`{clear}` (remove it), never both — nothing was written"
)
}
pub(super) fn time_arguments(args: &Value, allowed: &[&str]) -> Option<Value> {
let object = args.as_object()?;
let unknown: Vec<&str> = object
.keys()
.map(String::as_str)
.filter(|key| !allowed.contains(key))
.collect();
if unknown.is_empty() {
return None;
}
let listed = unknown
.iter()
.map(|key| format!("`{key}`"))
.collect::<Vec<String>>()
.join(", ");
let verb = if unknown.len() == 1 { "is" } else { "are" };
Some(error_result(format!(
"invalid input: {listed} {verb} not a field of this tool. The time model is one duration in \
whole minutes plus an optional `YYYY-MM-DD` calendar date, an author and an optional note \
(C-002): there is no timer, no start/stop state, no timezone conversion, no overlap rule \
and no billing, payroll or invoicing field, and this surface has none either."
)))
}
pub(super) fn time_refusal(error: &crate::error::TixError) -> Value {
error_result(crate::error::render_error(error))
}
pub(super) fn time_input_refusal(sentence: String) -> Value {
time_refusal(&crate::error::TixError::InvalidInput(sentence))
}
pub(super) fn time_minutes_value(minutes: Option<i128>) -> std::result::Result<Value, String> {
match minutes {
None => Ok(Value::Null),
Some(minutes) => u64::try_from(minutes)
.map(|minutes| Value::Number(minutes.into()))
.map_err(|_| {
format!(
"internal error: the counted total is {minutes} minutes, which is not a number \
this payload can carry; the text channel carries it, and nothing was written"
)
}),
}
}
pub(super) fn time_diagnostic_value(finding: &TimeDiagnostic) -> Value {
json!({
"kind": finding.kind.as_str(),
"issue": finding.issue,
"entry_id": finding.entry_id,
"field": finding.field,
"reason": finding.reason,
"remedy": finding.remedy,
"scope": if finding.is_scope_finding() { "scope" } else { "entry" },
})
}
pub(super) fn time_diagnostics_value(findings: &[TimeDiagnostic]) -> Value {
Value::Array(findings.iter().map(time_diagnostic_value).collect())
}
pub(super) fn time_entry_value(issue: u32, entry: &TimeEntry) -> Value {
json!({
"issue": issue,
"id": entry.id,
"duration_minutes": entry.duration_minutes,
"date": entry.date,
"author": entry.author,
"note": entry.note,
"extensions": yaml_map_value(entry.extensions.as_map()),
})
}
pub(super) fn time_total_value(total: &TimeTotal) -> std::result::Result<Value, String> {
Ok(json!({
"group": total.group.label(),
"scope": total.filter.describe(),
"state": total.state().as_str(),
"total_minutes": time_minutes_value(total.total_minutes)?,
"entry_count": total.entry_count,
"malformed_count": total.malformed_count,
"undated_count": total.undated_count,
"is_partial": total.is_partial(),
"deleted_count": total.deleted_count,
"diagnostics": time_diagnostics_value(&total.diagnostics),
}))
}
pub(super) fn time_scope_payload(
total: &TimeTotal,
) -> std::result::Result<(String, Value), String> {
if matches!(
total.state(),
TimeTotalState::InvalidFilter | TimeTotalState::UnknownIssue
) {
return Err(cmd_time::refusal_blocks(&total.diagnostics));
}
Ok((cmd_time::scope_block(total), time_total_value(total)?))
}
pub(super) fn time_issue_snapshot(store: &Store, selector: &str) -> TixResult<(u32, TimeSnapshot)> {
let issue = store.resolve_issue(selector)?;
let snapshot = TimeSnapshot::of(std::slice::from_ref(&issue));
Ok((issue.number, snapshot))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum TimeMutationStatus {
Created,
Updated,
Unchanged,
Deleted,
}
impl TimeMutationStatus {
const fn as_str(self) -> &'static str {
match self {
Self::Created => "created",
Self::Updated => "updated",
Self::Unchanged => "unchanged",
Self::Deleted => "deleted",
}
}
}
pub(super) fn time_request(
args: &Value,
request: TimeMutationRequest,
) -> std::result::Result<TimeMutationRequest, ()> {
let mut request = request;
if let Some(actor) = opt_string(args, "actor")? {
request = request.by(actor);
}
if let Some(timestamp) = opt_string(args, "timestamp")? {
request = request.at(timestamp);
}
if let Some(reason) = opt_string(args, "reason")? {
request.attribution.reason = Some(reason);
}
Ok(request)
}
pub(super) fn time_mutation(store: &Store, request: &TimeMutationRequest) -> ToolOutcome {
let applied = match apply_time(store, request) {
Ok(applied) => applied,
Err(error) => return Ok(time_refusal(&error)),
};
let outcome = &applied.outcome;
let number = applied.issue.number;
let status = if !outcome.changed {
TimeMutationStatus::Unchanged
} else if outcome.before.is_none() {
TimeMutationStatus::Created
} else if outcome.after.is_some() {
TimeMutationStatus::Updated
} else {
TimeMutationStatus::Deleted
};
let text = cmd_time::mutation_sentence(&applied);
let structured = json!({
"status": status.as_str(),
"issue": number,
"entry_id": outcome.entry_id,
"changed": outcome.changed,
"before": outcome.before.as_ref().map(|entry| time_entry_value(number, entry)),
"after": outcome.after.as_ref().map(|entry| time_entry_value(number, entry)),
"history": applied.event().map(|event| json!({
"kind": event.kind.as_str(),
"actor": event.actor,
"timestamp": event.timestamp,
"detail": event.detail,
})),
});
Ok(success_result(format!("{text}\n"), structured))
}