#![allow(clippy::module_name_repetitions)]
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::capacity::calculate::{CapacityCalculation, CapacityScope, CapacitySource};
use crate::capacity::config::CapacityScopeKind;
use crate::capacity::resolve_cycle;
use crate::capacity::scope::IssueSnapshot;
use crate::capacity::snapshot::CapacitySnapshot;
use crate::capacity::warning::CapacityWarning;
use crate::config::Config;
use crate::cycle_schedule::identity::{CycleReference, GeneratedCycleId, ScheduleIndex};
use crate::cycle_schedule::snapshot::{CycleSnapshotMetadata, CycleSnapshotStore};
use crate::cycle_store::{CycleRecord, CycleStore};
use crate::error::{Result, TixError};
use crate::model::{History, HistoryEventKind};
use crate::records::RecordStore;
pub const FORMAT: &str = "tix-capacity";
pub const VERSION: u32 = 1;
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_toml(table: &toml::Table) -> (Self, Vec<NotCarried>) {
let mut bag = Self::new();
let mut not_carried = Vec::new();
for (key, value) in table {
match toml_to_json(value) {
Ok(json) => {
bag.insert(key.clone(), json);
}
Err(reason) => not_carried.push(NotCarried {
key: key.clone(),
reason,
}),
}
}
(bag, not_carried)
}
fn from_yaml(metadata: &CycleSnapshotMetadata) -> (Self, Vec<NotCarried>) {
let mut bag = Self::new();
let mut not_carried = Vec::new();
for (key, value) in metadata {
match yaml_to_json(value) {
Ok(json) => {
bag.insert(key.clone(), json);
}
Err(reason) => not_carried.push(NotCarried {
key: key.clone(),
reason,
}),
}
}
(bag, not_carried)
}
}
#[derive(Debug, Clone)]
struct NotCarried {
key: String,
reason: String,
}
fn toml_to_json(value: &toml::Value) -> std::result::Result<serde_json::Value, String> {
Ok(match value {
toml::Value::String(text) => serde_json::Value::String(text.clone()),
toml::Value::Integer(number) => serde_json::Value::from(*number),
toml::Value::Float(number) => {
let Some(number) = serde_json::Number::from_f64(*number) else {
return Err(format!(
"the float `{number}` is not finite, and JSON has no number for it"
));
};
serde_json::Value::Number(number)
}
toml::Value::Boolean(flag) => serde_json::Value::Bool(*flag),
toml::Value::Datetime(datetime) => serde_json::Value::String(datetime.to_string()),
toml::Value::Array(items) => {
let mut array = Vec::with_capacity(items.len());
for item in items {
array.push(toml_to_json(item)?);
}
serde_json::Value::Array(array)
}
toml::Value::Table(table) => {
let mut map = serde_json::Map::new();
for (key, item) in table {
map.insert(key.clone(), toml_to_json(item)?);
}
serde_json::Value::Object(map)
}
})
}
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 PortableScope {
pub kind: String,
pub reference: String,
pub display: String,
}
impl PortableScope {
#[must_use]
pub fn from_scope(scope: &CapacityScope) -> Self {
Self {
kind: scope.kind.as_str().to_string(),
reference: scope.reference.clone(),
display: scope.display.clone(),
}
}
#[must_use]
pub fn describe(&self) -> String {
format!("{} `{}`", self.kind, self.display)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableSource {
pub issues: usize,
#[serde(default)]
pub units: Vec<String>,
}
impl PortableSource {
#[must_use]
fn from_source(source: &CapacitySource) -> Self {
Self {
issues: source.issues,
units: source.units.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableCapacityDiagnostic {
pub kind: String,
#[serde(default)]
pub scope: Option<PortableScope>,
#[serde(default)]
pub issues: Vec<u32>,
#[serde(default)]
pub current: Option<u64>,
#[serde(default)]
pub limit: Option<u64>,
#[serde(default)]
pub overflow: Option<u64>,
#[serde(default)]
pub unit: Option<String>,
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub target: Option<String>,
pub reason: String,
#[serde(default)]
pub remedy: Option<String>,
}
impl PortableCapacityDiagnostic {
#[must_use]
pub fn from_warning(warning: &CapacityWarning) -> Self {
Self {
kind: warning.kind.as_str().to_string(),
scope: Some(PortableScope::from_scope(&warning.scope)),
issues: warning.issues.clone(),
current: warning.current,
limit: warning.limit,
overflow: warning.overflow,
unit: warning.unit.clone(),
field: None,
target: None,
reason: warning.reason.clone(),
remedy: warning.remedy.clone(),
}
}
fn unknown_metadata(field: String, scope: PortableScope, keys: Vec<String>) -> Self {
Self {
kind: UNKNOWN_METADATA.to_string(),
scope: Some(scope),
issues: Vec::new(),
current: None,
limit: None,
overflow: None,
unit: 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: Some(
"keep the metadata: a build that understands the key reads it back unchanged"
.to_string(),
),
}
}
fn metadata_not_carried(field: String, scope: PortableScope, not_carried: &NotCarried) -> Self {
Self {
kind: METADATA_NOT_CARRIED.to_string(),
scope: Some(scope),
issues: Vec::new(),
current: None,
limit: None,
overflow: None,
unit: 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: Some(
"rewrite the value in a form JSON can represent (a finite number, a date as an \
RFC 3339 string), or remove the key"
.to_string(),
),
}
}
fn unsupported_remote(target: &str, scopes: usize, snapshots: usize) -> Self {
Self {
kind: UNSUPPORTED_REMOTE_FIELD.to_string(),
scope: None,
issues: Vec::new(),
current: None,
limit: None,
overflow: None,
unit: None,
field: Some("capacity".to_string()),
target: Some(target.to_string()),
reason: format!(
"`{target}` has no capacity field, so the {scopes} configured scope(s) and \
{snapshots} closed-cycle snapshot(s) in this document stay local; nothing is \
mapped and nothing is dropped"
),
remedy: Some(format!(
"keep the local document: `tix export --target {target}` mirrors issues, and \
capacity is a local interchange value rather than a remote field"
)),
}
}
fn version_mismatch(found: u32) -> Self {
Self {
kind: VERSION_MISMATCH.to_string(),
scope: None,
issues: Vec::new(),
current: None,
limit: None,
overflow: None,
unit: 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: Some(
"read it with a build that writes that version; this one preserves the fields it \
does not interpret"
.to_string(),
),
}
}
#[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 PortableCalculation {
pub scope: PortableScope,
pub state: String,
#[serde(default)]
pub capacity: Option<u64>,
#[serde(default)]
pub unit: Option<String>,
#[serde(default)]
pub committed: Option<u64>,
#[serde(default)]
pub completed: Option<u64>,
#[serde(default)]
pub remaining: Option<u64>,
#[serde(default)]
pub overflow: Option<u64>,
#[serde(default)]
pub utilization: Option<u32>,
#[serde(default)]
pub counted_issues: Option<u32>,
#[serde(default)]
pub uncounted_issues: Option<u32>,
#[serde(default)]
pub unestimated_issues: Option<u32>,
pub source: PortableSource,
#[serde(default)]
pub diagnostics: Vec<PortableCapacityDiagnostic>,
}
impl PortableCalculation {
#[must_use]
fn from_calculation(calculation: &CapacityCalculation) -> Self {
Self {
scope: PortableScope::from_scope(&calculation.scope),
state: calculation.state().as_str().to_string(),
capacity: calculation.capacity,
unit: calculation.unit.clone(),
committed: calculation.committed,
completed: calculation.completed,
remaining: calculation.remaining,
overflow: calculation.overflow,
utilization: calculation.utilization,
counted_issues: calculation.counted_issues,
uncounted_issues: calculation.uncounted_issues,
unestimated_issues: calculation.unestimated_issues,
source: PortableSource::from_source(&calculation.source),
diagnostics: calculation
.diagnostics
.iter()
.map(PortableCapacityDiagnostic::from_warning)
.collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableCapacityScope {
pub id: String,
pub scope: PortableScope,
pub enabled: bool,
pub effective: bool,
#[serde(default)]
pub capacity: Option<i64>,
#[serde(default)]
pub unit: Option<String>,
pub calculation: PortableCalculation,
#[serde(default)]
pub extensions: PortableExtensions,
#[serde(flatten)]
pub unknown: PortableExtensions,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableCapacitySnapshot {
pub snapshot_id: String,
pub cycle_id: String,
#[serde(default)]
pub cycle_name: Option<String>,
pub closed_at: String,
pub closed_by: String,
pub scope: PortableScope,
pub state: String,
#[serde(default)]
pub capacity: Option<u64>,
#[serde(default)]
pub unit: Option<String>,
#[serde(default)]
pub committed: Option<u64>,
#[serde(default)]
pub completed: Option<u64>,
#[serde(default)]
pub remaining: Option<u64>,
#[serde(default)]
pub overflow: Option<u64>,
#[serde(default)]
pub utilization: Option<u32>,
#[serde(default)]
pub counted_issues: Option<u32>,
#[serde(default)]
pub uncounted_issues: Option<u32>,
#[serde(default)]
pub unestimated_issues: Option<u32>,
pub source: PortableSource,
#[serde(default)]
pub diagnostics: Vec<PortableCapacityDiagnostic>,
#[serde(default)]
pub breakdown: Option<PortableExtensions>,
#[serde(default)]
pub extensions: PortableExtensions,
#[serde(flatten)]
pub unknown: PortableExtensions,
}
impl PortableCapacitySnapshot {
fn from_snapshot(
evidence: &CapacitySnapshot,
index: &ScheduleIndex,
) -> Result<(Self, Vec<(String, NotCarried)>)> {
let cycle_name = GeneratedCycleId::parse(&evidence.cycle_id)
.ok()
.and_then(|id| index.cycle(&id))
.map(|cycle| cycle.name.clone());
let (extensions, mut not_carried) = PortableExtensions::from_yaml(&evidence.extensions);
let mut fields = Vec::new();
for missing in not_carried.drain(..) {
fields.push((String::from("extensions"), missing));
}
let breakdown = match &evidence.breakdown {
Some(metadata) => {
let (bag, missing) = PortableExtensions::from_yaml(metadata);
for not_carried in missing {
fields.push((String::from("breakdown"), not_carried));
}
Some(bag)
}
None => None,
};
Ok((
Self {
snapshot_id: evidence.snapshot_id.clone(),
cycle_id: evidence.cycle_id.clone(),
cycle_name,
closed_at: evidence.closed_at.clone(),
closed_by: evidence.closed_by.clone(),
scope: PortableScope::from_scope(&evidence.scope),
state: evidence.state.as_str().to_string(),
capacity: evidence.capacity,
unit: evidence.unit.clone(),
committed: evidence.committed,
completed: evidence.completed,
remaining: evidence.remaining,
overflow: evidence.overflow,
utilization: evidence.utilization,
counted_issues: evidence.counted_issues,
uncounted_issues: evidence.uncounted_issues,
unestimated_issues: evidence.unestimated_issues,
source: PortableSource::from_source(&evidence.source),
diagnostics: evidence
.diagnostics
.iter()
.map(PortableCapacityDiagnostic::from_warning)
.collect(),
breakdown,
extensions,
unknown: PortableExtensions::new(),
},
fields,
))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableCapacityHistoryEvent {
pub cycle_id: String,
pub cycle_name: String,
pub kind: String,
pub timestamp: String,
pub actor: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableCapacityDocument {
pub format: String,
pub version: u32,
#[serde(default)]
pub scopes: Vec<PortableCapacityScope>,
#[serde(default)]
pub snapshots: Vec<PortableCapacitySnapshot>,
#[serde(default)]
pub history: Vec<PortableCapacityHistoryEvent>,
#[serde(default)]
pub diagnostics: Vec<PortableCapacityDiagnostic>,
#[serde(default)]
pub extensions: PortableExtensions,
#[serde(flatten)]
pub unknown: PortableExtensions,
}
#[derive(Deserialize)]
struct Marker {
format: String,
}
struct CycleFilter {
id: Option<String>,
name: String,
label: String,
}
impl CycleFilter {
fn resolve(index: &ScheduleIndex, reference: &str) -> Result<Self> {
let (id, name) = match resolve_cycle(index, reference) {
CycleReference::Generated { id, name, .. } => (Some(id.as_str().to_string()), name),
CycleReference::Legacy { name } => (None, name),
CycleReference::Unknown { name } => {
return Err(TixError::NotFound(format!(
"no cycle `{name}` resolves to a record, so there is no capacity to export \
for it"
)));
}
};
let label = CapacityScope::new(CapacityScopeKind::Cycle, reference, index).display;
Ok(Self { id, name, label })
}
fn keeps_scope(&self, scope: &PortableScope) -> bool {
scope.kind == CapacityScopeKind::Cycle.as_str() && scope.display == self.label
}
fn keeps_snapshot(&self, snapshot: &PortableCapacitySnapshot) -> bool {
self.id.as_deref() == Some(snapshot.cycle_id.as_str())
}
fn keeps_history(&self, event: &PortableCapacityHistoryEvent) -> bool {
self.id.as_deref() == Some(event.cycle_id.as_str()) || event.cycle_name == self.name
}
}
impl PortableCapacityDocument {
pub fn build(
config: &Config,
root: &Path,
index: &ScheduleIndex,
snapshot: &IssueSnapshot,
) -> Result<Self> {
Self::assemble(config, root, index, snapshot, None)
}
pub fn build_for_cycle(
config: &Config,
root: &Path,
index: &ScheduleIndex,
snapshot: &IssueSnapshot,
reference: &str,
) -> Result<Self> {
let filter = CycleFilter::resolve(index, reference)?;
Self::assemble(config, root, index, snapshot, Some(filter))
}
#[allow(clippy::too_many_lines)]
fn assemble(
config: &Config,
root: &Path,
index: &ScheduleIndex,
snapshot: &IssueSnapshot,
filter: Option<CycleFilter>,
) -> Result<Self> {
let mut document = Self {
format: FORMAT.to_string(),
version: VERSION,
scopes: Vec::new(),
snapshots: Vec::new(),
history: Vec::new(),
diagnostics: Vec::new(),
extensions: PortableExtensions::new(),
unknown: PortableExtensions::new(),
};
for (id, entry) in config.capacity.iter() {
let Some(kind) = entry.scope_kind() else {
continue;
};
let reference = entry.scope_id().unwrap_or_default();
let scope = CapacityScope::new(kind, reference, index);
let projected = PortableScope::from_scope(&scope);
if filter
.as_ref()
.is_some_and(|filter| !filter.keeps_scope(&projected))
{
continue;
}
let calculation = CapacityCalculation::for_scope(snapshot, index, config, scope);
let (extensions, not_carried) = PortableExtensions::from_toml(entry.extensions.table());
if !extensions.is_empty() {
document
.diagnostics
.push(PortableCapacityDiagnostic::unknown_metadata(
format!("capacity.{id}"),
projected.clone(),
extensions.keys().map(str::to_string).collect(),
));
}
for missing in ¬_carried {
document
.diagnostics
.push(PortableCapacityDiagnostic::metadata_not_carried(
format!("capacity.{id}"),
projected.clone(),
missing,
));
}
document.scopes.push(PortableCapacityScope {
id: id.to_string(),
scope: projected,
enabled: entry.enabled,
effective: entry.effective(),
capacity: entry.capacity,
unit: entry.unit.clone(),
calculation: PortableCalculation::from_calculation(&calculation),
extensions,
unknown: PortableExtensions::new(),
});
}
let store = CycleSnapshotStore::new(root);
for stored in store.list()? {
let Some(evidence) = CapacitySnapshot::from_cycle_snapshot(&stored)? else {
continue;
};
let (projected, not_carried) =
PortableCapacitySnapshot::from_snapshot(&evidence, index)?;
if filter
.as_ref()
.is_some_and(|filter| !filter.keeps_snapshot(&projected))
{
continue;
}
let field = format!("snapshots.{}", projected.snapshot_id);
if !projected.extensions.is_empty() {
document
.diagnostics
.push(PortableCapacityDiagnostic::unknown_metadata(
field.clone(),
projected.scope.clone(),
projected.extensions.keys().map(str::to_string).collect(),
));
}
for (channel, missing) in ¬_carried {
document
.diagnostics
.push(PortableCapacityDiagnostic::metadata_not_carried(
format!("{field}.{channel}"),
projected.scope.clone(),
missing,
));
}
document.snapshots.push(projected);
}
for record in RecordStore::new(CycleStore::new(root).dir()).list::<CycleRecord>()? {
let (history, _) = History::parse_front(&record.body);
let cycle_id = match index.resolve(&record.value.name) {
CycleReference::Generated { id, .. } => id.as_str().to_string(),
_ => record.value.name.clone(),
};
for event in history.events() {
if !matches!(
event.kind,
HistoryEventKind::CycleClosed
| HistoryEventKind::CycleReopened
| HistoryEventKind::CycleCapacityEdited
) {
continue;
}
let projected = PortableCapacityHistoryEvent {
cycle_id: cycle_id.clone(),
cycle_name: record.value.name.clone(),
kind: event.kind.as_str().to_string(),
timestamp: event.timestamp.clone(),
actor: event.actor.clone(),
detail: event.detail.clone(),
};
if filter
.as_ref()
.is_some_and(|filter| !filter.keeps_history(&projected))
{
continue;
}
document.history.push(projected);
}
}
document.diagnostics.extend(remote_diagnostics(
config,
document.scopes.len(),
document.snapshots.len(),
));
Ok(document)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.scopes.is_empty() && self.snapshots.is_empty() && self.history.is_empty()
}
#[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);
}
pub fn to_json(&self) -> Result<String> {
serde_json::to_string(self).map_err(|error| {
TixError::Parse(format!("capacity 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!("capacity 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 capacity document: {error}"))
})?;
if marker.format != FORMAT {
return Err(TixError::Parse(format!(
"portable capacity 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 capacity document is not valid JSON of this shape: {error}"
))
})?;
if document.version != VERSION {
let diagnostic = PortableCapacityDiagnostic::version_mismatch(document.version);
if !document
.diagnostics
.iter()
.any(|existing| existing.same_as(&diagnostic))
{
document.diagnostics.push(diagnostic);
}
}
Ok(document)
}
}
fn remote_diagnostics(
config: &Config,
scopes: usize,
snapshots: usize,
) -> Vec<PortableCapacityDiagnostic> {
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();
}
targets
.into_iter()
.map(|target| PortableCapacityDiagnostic::unsupported_remote(target, scopes, snapshots))
.collect()
}