#![allow(clippy::module_name_repetitions)]
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::diagnostics::Diagnostic;
use crate::error::{Result, TixError};
use crate::time_tracking::TIME_ENTRY_FRONTMATTER_KEY;
use crate::time_tracking::aggregate::{
IndexedEntry, TimeGroup, TimeSnapshot, TimeTotal, TimeTotalState,
};
use crate::time_tracking::diagnostics::{TimeDiagnostic, TimeDiagnosticKind};
use crate::time_tracking::entry::{TimeEntry, TimeEntryExtensions};
use crate::time_tracking::filters::TimeFilter;
pub const FORMAT: &str = "tix-time";
pub const VERSION: u32 = 1;
pub const PRECISION: &str = "minutes";
pub const UNSUPPORTED_REMOTE_FIELD: &str = "unsupported_remote_field";
pub const UNKNOWN_METADATA: &str = "unknown_metadata";
pub const METADATA_NOT_CARRIED: &str = "metadata_not_carried";
pub const VERSION_MISMATCH: &str = "version_mismatch";
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PortableExtensions(serde_json::Map<String, serde_json::Value>);
impl PortableExtensions {
#[must_use]
pub fn new() -> Self {
Self(serde_json::Map::new())
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
self.0.get(key)
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.0.keys().map(String::as_str)
}
#[must_use]
pub fn as_map(&self) -> &serde_json::Map<String, serde_json::Value> {
&self.0
}
fn insert(
&mut self,
key: impl Into<String>,
value: serde_json::Value,
) -> Option<serde_json::Value> {
self.0.insert(key.into(), value)
}
fn from_yaml(extensions: &TimeEntryExtensions) -> (Self, Vec<NotCarried>) {
let mut bag = Self::new();
let mut not_carried = Vec::new();
for (key, value) in extensions.iter() {
match yaml_to_json(value) {
Ok(json) => {
bag.insert(key, json);
}
Err(reason) => not_carried.push(NotCarried {
key: key.to_string(),
reason,
}),
}
}
(bag, not_carried)
}
}
#[derive(Debug, Clone)]
struct NotCarried {
key: String,
reason: String,
}
fn yaml_to_json(value: &serde_yaml::Value) -> std::result::Result<serde_json::Value, String> {
Ok(match value {
serde_yaml::Value::Null => serde_json::Value::Null,
serde_yaml::Value::Bool(flag) => serde_json::Value::Bool(*flag),
serde_yaml::Value::Number(number) => {
if let Some(signed) = number.as_i64() {
serde_json::Value::from(signed)
} else if let Some(unsigned) = number.as_u64() {
serde_json::Value::from(unsigned)
} else {
let Some(float) = number.as_f64() else {
return Err(format!("the number `{number:?}` has no JSON form"));
};
let Some(number) = serde_json::Number::from_f64(float) else {
return Err(format!(
"the number `{float}` is not finite, and JSON has no number for it"
));
};
serde_json::Value::Number(number)
}
}
serde_yaml::Value::String(text) => serde_json::Value::String(text.clone()),
serde_yaml::Value::Sequence(items) => {
let mut array = Vec::with_capacity(items.len());
for item in items {
array.push(yaml_to_json(item)?);
}
serde_json::Value::Array(array)
}
serde_yaml::Value::Mapping(mapping) => {
let mut map = serde_json::Map::new();
for (key, item) in mapping {
let serde_yaml::Value::String(key) = key else {
return Err(
"a mapping key is not a string, and JSON cannot carry one".to_string()
);
};
map.insert(key.clone(), yaml_to_json(item)?);
}
serde_json::Value::Object(map)
}
serde_yaml::Value::Tagged(tagged) => {
return Err(format!(
"the value carries the YAML tag `{}`, which JSON cannot carry",
tagged.tag
));
}
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableTimeDiagnostic {
pub kind: String,
#[serde(default)]
pub issue: Option<u32>,
#[serde(default)]
pub entry_id: Option<String>,
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub target: Option<String>,
pub reason: String,
pub remedy: String,
}
impl PortableTimeDiagnostic {
#[must_use]
pub fn from_shared(finding: &TimeDiagnostic) -> Self {
Self {
kind: finding.kind.as_str().to_string(),
issue: finding.issue,
entry_id: finding.entry_id.clone(),
field: finding.field.map(str::to_string),
target: None,
reason: finding.reason.clone(),
remedy: finding.remedy.clone(),
}
}
fn on_target(mut self, target: &str) -> Self {
self.target = Some(target.to_string());
self
}
fn unknown_metadata(field: String, keys: Vec<String>) -> Self {
Self {
kind: UNKNOWN_METADATA.to_string(),
issue: None,
entry_id: None,
field: Some(field),
target: None,
reason: format!(
"{} key(s) this build does not interpret are preserved verbatim under \
`extensions`: {}",
keys.len(),
keys.join(", ")
),
remedy: "keep the metadata: a build that understands the key reads it back unchanged"
.to_string(),
}
}
fn metadata_not_carried(field: String, not_carried: &NotCarried) -> Self {
Self {
kind: METADATA_NOT_CARRIED.to_string(),
issue: None,
entry_id: None,
field: Some(format!("{field}.{}", not_carried.key)),
target: None,
reason: format!(
"`{key}` is not carried: {reason}",
key = not_carried.key,
reason = not_carried.reason
),
remedy: "rewrite the value in a form JSON can represent (a finite number, a string, a \
sequence, or a mapping with string keys), or remove the key"
.to_string(),
}
}
fn unsupported_remote(target: &str, counts: RemoteCounts) -> Self {
let finding = TimeDiagnostic::new(
TimeDiagnosticKind::UnsupportedRemoteField,
format!(
"`{target}` has no `{TIME_ENTRY_FRONTMATTER_KEY}` field, so this document's \
{issues} issue row(s), their current entries, {deletions} deletion(s) and \
{corrections} correction(s) stay local; nothing is mapped and nothing is dropped",
issues = counts.issues,
deletions = counts.deletions,
corrections = counts.corrections,
),
);
Self::from_shared(&finding)
.on_target(target)
.in_field(TIME_ENTRY_FRONTMATTER_KEY)
}
fn version_mismatch(found: u32) -> Self {
Self {
kind: VERSION_MISMATCH.to_string(),
issue: None,
entry_id: None,
field: Some("version".to_string()),
target: None,
reason: format!(
"the document declares version {found}, but this build writes version {VERSION}; \
its fields are preserved rather than refused or dropped"
),
remedy:
"read it with a build that writes that version; this one preserves the fields it \
does not interpret"
.to_string(),
}
}
fn in_field(mut self, field: &str) -> Self {
self.field = Some(field.to_string());
self
}
#[must_use]
pub fn kind(&self) -> &str {
&self.kind
}
#[must_use]
pub fn render(&self) -> String {
match self.field.as_deref() {
Some(field) => format!("{} [{field}]: {}", self.kind, self.reason),
None => format!("{}: {}", self.kind, self.reason),
}
}
#[must_use]
fn same_as(&self, other: &Self) -> bool {
self.kind == other.kind
&& self.field == other.field
&& self.target == other.target
&& self.reason == other.reason
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableTimeFilter {
#[serde(default)]
pub issue: Option<u32>,
#[serde(default)]
pub assignee: Option<String>,
#[serde(default)]
pub assignee_identity: Option<String>,
#[serde(default)]
pub date_from: Option<String>,
#[serde(default)]
pub date_to: Option<String>,
pub include_deleted: bool,
pub valid: bool,
pub description: String,
}
impl PortableTimeFilter {
#[must_use]
pub fn from_filter(filter: &TimeFilter) -> Self {
Self {
issue: filter.issue,
assignee: filter.assignee.clone(),
assignee_identity: filter.canonical_assignee(),
date_from: filter.date_from.clone(),
date_to: filter.date_to.clone(),
include_deleted: filter.include_deleted,
valid: filter.is_valid(),
description: filter.describe(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableTimeEntry {
pub id: String,
#[serde(default)]
pub duration_minutes: Option<i64>,
#[serde(default)]
pub date: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub minutes: Option<i64>,
pub valid: bool,
#[serde(default)]
pub diagnostics: Vec<PortableTimeDiagnostic>,
#[serde(default)]
pub extensions: PortableExtensions,
#[serde(flatten)]
unknown: PortableExtensions,
}
impl PortableTimeEntry {
#[must_use]
pub fn unknown(&self) -> &PortableExtensions {
&self.unknown
}
fn of_entry(issue: u32, entry: &TimeEntry) -> (Self, Vec<NotCarried>) {
let diagnostics = entry
.problems()
.iter()
.map(|problem| {
PortableTimeDiagnostic::from_shared(&TimeDiagnostic::from_entry_problem(
issue, &entry.id, problem,
))
})
.collect();
let (extensions, not_carried) = PortableExtensions::from_yaml(&entry.extensions);
(
Self {
id: entry.id.clone(),
duration_minutes: entry.duration_minutes,
date: entry.date.clone(),
author: answerable(&entry.author),
note: entry.note.clone(),
minutes: entry.minutes(),
valid: entry.is_valid(),
diagnostics,
extensions,
unknown: PortableExtensions::new(),
},
not_carried,
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableTimeDeletion {
#[serde(default)]
pub entry_id: Option<String>,
#[serde(default)]
pub deleted_at: Option<String>,
#[serde(default)]
pub deleted_by: Option<String>,
}
fn answerable(value: &str) -> Option<String> {
if value.trim().is_empty() {
None
} else {
Some(value.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableTimeTotal {
pub group: String,
pub scope: PortableTimeFilter,
pub state: String,
pub is_partial: bool,
#[serde(default)]
pub total_minutes: Option<i128>,
pub entry_count: usize,
pub malformed_count: usize,
pub undated_count: usize,
#[serde(default)]
pub deleted_count: Option<usize>,
#[serde(default)]
pub diagnostics: Vec<PortableTimeDiagnostic>,
}
impl PortableTimeTotal {
#[must_use]
pub fn from_total(total: &TimeTotal) -> Self {
Self {
group: total.group.label(),
scope: PortableTimeFilter::from_filter(&total.filter),
state: total.state().as_str().to_string(),
is_partial: total.is_partial(),
total_minutes: total.total_minutes,
entry_count: total.entry_count,
malformed_count: total.malformed_count,
undated_count: total.undated_count,
deleted_count: total.deleted_count,
diagnostics: total
.diagnostics
.iter()
.map(PortableTimeDiagnostic::from_shared)
.collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableTimeAudit {
pub scope: PortableTimeFilter,
pub deleted_count: usize,
pub corrected_count: usize,
#[serde(default)]
pub diagnostics: Vec<PortableTimeDiagnostic>,
}
impl PortableTimeAudit {
#[must_use]
pub fn from_audit(
filter: &TimeFilter,
deleted_count: usize,
corrected_count: usize,
diagnostics: &[TimeDiagnostic],
) -> Self {
Self {
scope: PortableTimeFilter::from_filter(filter),
deleted_count,
corrected_count,
diagnostics: diagnostics
.iter()
.map(PortableTimeDiagnostic::from_shared)
.collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableTimeIssue {
pub number: u32,
#[serde(default)]
pub entries: Vec<PortableTimeEntry>,
pub current: usize,
#[serde(default)]
pub deletions: Vec<PortableTimeDeletion>,
pub corrections: usize,
#[serde(default)]
pub total: Option<PortableTimeTotal>,
#[serde(flatten)]
unknown: PortableExtensions,
}
impl PortableTimeIssue {
#[must_use]
pub fn unknown(&self) -> &PortableExtensions {
&self.unknown
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableTimeDocument {
pub format: String,
pub version: u32,
pub precision: String,
pub filter: PortableTimeFilter,
pub total: PortableTimeTotal,
pub audit: PortableTimeAudit,
#[serde(default)]
pub issues: Vec<PortableTimeIssue>,
#[serde(default)]
pub diagnostics: Vec<PortableTimeDiagnostic>,
#[serde(default)]
pub extensions: PortableExtensions,
#[serde(flatten)]
unknown: PortableExtensions,
}
#[derive(Deserialize)]
struct Marker {
format: String,
}
#[derive(Debug, Clone, Copy)]
struct RemoteCounts {
issues: usize,
deletions: usize,
corrections: usize,
}
impl PortableTimeDocument {
pub fn build(config: &Config, snapshot: &TimeSnapshot, filter: &TimeFilter) -> Result<Self> {
let total = snapshot.total(filter);
if matches!(
total.state(),
TimeTotalState::InvalidFilter | TimeTotalState::UnknownIssue
) {
return Err(refusal(&total.diagnostics));
}
let audit = snapshot.audit(filter);
let breakdown: BTreeMap<u32, TimeTotal> = snapshot
.by_issue(filter)
.into_iter()
.filter_map(|group| match group.group {
TimeGroup::Issue(number) => Some((number, group)),
_ => None,
})
.collect();
let entries_by_issue = index_entries(snapshot);
let mut document = Self {
format: FORMAT.to_string(),
version: VERSION,
precision: PRECISION.to_string(),
filter: PortableTimeFilter::from_filter(filter),
total: PortableTimeTotal::from_total(&total),
audit: PortableTimeAudit::from_audit(
&audit.filter,
audit.deleted_count,
audit.corrected_count,
&audit.diagnostics,
),
issues: Vec::new(),
diagnostics: Vec::new(),
extensions: PortableExtensions::new(),
unknown: PortableExtensions::new(),
};
for row in &audit.issues {
let number = row.issue;
let mut entries = Vec::new();
if let Some(indexed) = entries_by_issue.get(&number) {
for indexed in indexed {
if !filter.selects(indexed.issue, &indexed.entry) {
continue;
}
let bag = format!("issues.{number}.entries.{}.extensions", indexed.entry.id);
let (projected, not_carried) =
PortableTimeEntry::of_entry(number, &indexed.entry);
for missing in ¬_carried {
document
.diagnostics
.push(PortableTimeDiagnostic::metadata_not_carried(
bag.clone(),
missing,
));
}
if !projected.extensions.is_empty() {
document
.diagnostics
.push(PortableTimeDiagnostic::unknown_metadata(
bag,
projected.extensions.keys().map(str::to_string).collect(),
));
}
entries.push(projected);
}
}
document.issues.push(PortableTimeIssue {
number,
entries,
current: row.current,
deletions: row
.deletions
.iter()
.map(|deletion| PortableTimeDeletion {
entry_id: deletion.entry_id.clone(),
deleted_at: answerable(&deletion.deleted_at),
deleted_by: answerable(&deletion.deleted_by),
})
.collect(),
corrections: row.corrections,
total: breakdown.get(&number).map(PortableTimeTotal::from_total),
unknown: PortableExtensions::new(),
});
}
document
.diagnostics
.extend(remote_diagnostics(config, &document));
Ok(document)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.issues.iter().all(|issue| {
issue.entries.is_empty() && issue.deletions.is_empty() && issue.corrections == 0
})
}
#[must_use]
pub fn format(&self) -> &str {
&self.format
}
#[must_use]
pub const fn version(&self) -> u32 {
self.version
}
pub fn insert_extension(&mut self, key: impl Into<String>, value: serde_json::Value) {
self.extensions.insert(key, value);
}
#[must_use]
pub fn unknown(&self) -> &PortableExtensions {
&self.unknown
}
pub fn to_json(&self) -> Result<String> {
serde_json::to_string(self).map_err(|error| {
TixError::Parse(format!("time document cannot be serialized: {error}"))
})
}
pub fn to_json_pretty(&self) -> Result<String> {
serde_json::to_string_pretty(self).map_err(|error| {
TixError::Parse(format!("time document cannot be serialized: {error}"))
})
}
pub fn from_json(raw: &str) -> Result<Self> {
let marker: Marker = serde_json::from_str(raw)
.map_err(|error| TixError::Parse(format!("not a portable time document: {error}")))?;
if marker.format != FORMAT {
return Err(TixError::Parse(format!(
"portable time document has format `{}`, but this build reads `{FORMAT}`",
marker.format
)));
}
let mut document: Self = serde_json::from_str(raw).map_err(|error| {
TixError::Parse(format!(
"portable time document is not valid JSON of this shape: {error}"
))
})?;
if document.version != VERSION {
let diagnostic = PortableTimeDiagnostic::version_mismatch(document.version);
if !document
.diagnostics
.iter()
.any(|existing| existing.same_as(&diagnostic))
{
document.diagnostics.push(diagnostic);
}
}
Ok(document)
}
}
fn index_entries(snapshot: &TimeSnapshot) -> BTreeMap<u32, Vec<&IndexedEntry>> {
let mut by_issue: BTreeMap<u32, Vec<&IndexedEntry>> = BTreeMap::new();
for indexed in snapshot.entries() {
by_issue.entry(indexed.issue).or_default().push(indexed);
}
by_issue
}
fn refusal(diagnostics: &[TimeDiagnostic]) -> TixError {
let block = diagnostics
.iter()
.map(|finding| {
let mut diagnostic = Diagnostic::error("E-tix-time", finding.reason.clone())
.with_note(format!("kind: {}", finding.kind.as_str()));
if let Some(field) = finding.field {
diagnostic = diagnostic.with_note(format!("field: {field}"));
}
if let Some(issue) = finding.issue {
diagnostic = diagnostic.with_note(format!("issue: #{issue}"));
}
diagnostic.with_help(&finding.remedy).render()
})
.collect::<Vec<String>>()
.join("\n");
TixError::Refused(block)
}
fn remote_diagnostics(
config: &Config,
document: &PortableTimeDocument,
) -> Vec<PortableTimeDiagnostic> {
let mut targets = Vec::new();
if config.export.gitlab.is_some() {
targets.push("gitlab");
}
if config.export.linear.is_some() {
targets.push("linear");
}
if targets.is_empty() {
return Vec::new();
}
let counts = RemoteCounts {
issues: document.issues.len(),
deletions: document.audit.deleted_count,
corrections: document.audit.corrected_count,
};
targets
.into_iter()
.map(|target| PortableTimeDiagnostic::unsupported_remote(target, counts))
.collect()
}