use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::roadmap::diagnostics::RoadmapDiagnostic;
use crate::roadmap::filter::RoadmapFilter;
use crate::roadmap::projection::{
self, InitiativeSummary, ProgressValue, RoadmapProjection, RoadmapRow, RoadmapSources,
SummaryContribution,
};
use crate::sync::{RemoteMappingDiagnostic, Tool};
pub const FORMAT: &str = "tix-roadmap";
pub const VERSION: u32 = 1;
pub const UNSUPPORTED_REMOTE_FIELD: &str = "unsupported_remote_field";
const PORTABLE_KNOWN_FIELDS: &[&str] = &[
"format",
"version",
"filters",
"rows",
"total",
"diagnostics",
];
#[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 contains_key(&self, key: &str) -> bool {
self.0.contains_key(key)
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
self.0.get(key)
}
pub fn insert(&mut self, key: impl Into<String>, value: serde_json::Value) -> Result<()> {
let key = key.into();
if PORTABLE_KNOWN_FIELDS.contains(&key.as_str()) {
return Err(TixError::InvalidInput(format!(
"`{key}` collides with a document field; rename the extension key"
)));
}
self.0.insert(key, value);
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableRoadmap {
pub format: String,
pub version: u32,
#[serde(default)]
pub filters: RoadmapFilter,
#[serde(default)]
pub rows: Vec<PortableInitiativeRow>,
#[serde(default)]
pub total: usize,
#[serde(default)]
pub diagnostics: Vec<PortableRoadmapDiagnostic>,
#[serde(flatten)]
pub extensions: PortableExtensions,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableInitiativeRow {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<String>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_date: Option<String>,
#[serde(default)]
pub references: Vec<PortableReference>,
pub summary: PortableSummary,
#[serde(default)]
pub diagnostics: Vec<PortableRoadmapDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableReference {
pub field: String,
pub target: String,
pub label: String,
pub resolution: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableSummary {
pub state: String,
#[serde(default)]
pub contributions: Vec<PortableContribution>,
#[serde(default)]
pub source_entities: Vec<String>,
#[serde(default)]
pub policy_versions: Vec<String>,
#[serde(default)]
pub diagnostics: Vec<PortableRoadmapDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableContribution {
pub field: String,
pub source_entity: String,
pub target: String,
pub label: String,
pub resolution: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<PortableValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub risk_state: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_version: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableValue {
pub done: usize,
pub total: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableRoadmapDiagnostic {
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initiative: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remedy: Option<String>,
}
impl PortableRoadmapDiagnostic {
#[must_use]
pub fn from_roadmap(diagnostic: &RoadmapDiagnostic) -> Self {
Self {
kind: diagnostic.kind.as_str().to_string(),
initiative: diagnostic.initiative.clone(),
field: diagnostic.field.clone(),
reference: diagnostic.reference.clone(),
source: diagnostic.source.clone(),
target: None,
message: diagnostic.message.clone(),
remedy: diagnostic.remedy.clone(),
}
}
#[must_use]
pub fn from_remote(initiative: &str, diagnostic: &RemoteMappingDiagnostic) -> Self {
Self {
kind: UNSUPPORTED_REMOTE_FIELD.to_string(),
initiative: Some(initiative.to_string()),
field: Some(diagnostic.field.clone()),
reference: Some(diagnostic.relation_id.clone()),
source: None,
target: Some(diagnostic.tool.as_str().to_string()),
message: diagnostic.message.clone(),
remedy: Some(
"keep the local record; the remote cannot hold this field, and a remote that \
cannot hold it never clears it"
.to_string(),
),
}
}
#[must_use]
pub fn render(&self) -> String {
let mut diagnostic =
crate::diagnostics::Diagnostic::warning("W-tix-roadmap", self.message.clone())
.with_note(format!(
"{}: {}",
self.kind,
self.initiative.as_deref().unwrap_or("-")
));
if let Some(field) = &self.field {
diagnostic = diagnostic.with_note(format!("field: {field}"));
}
if let Some(reference) = &self.reference {
diagnostic = diagnostic.with_note(format!("reference: {reference}"));
}
if let Some(target) = &self.target {
diagnostic = diagnostic.with_note(format!("target: {target}"));
}
if let Some(remedy) = &self.remedy {
diagnostic = diagnostic.with_help(remedy.clone());
}
diagnostic.render()
}
#[must_use]
pub fn same_as(&self, other: &Self) -> bool {
self == other
}
}
impl PortableRoadmap {
pub fn build(
config: &Config,
sources: &RoadmapSources<'_>,
filter: &RoadmapFilter,
) -> Result<Self> {
let projection = projection::project(config, sources, filter)?;
Ok(Self::from_projection(&projection))
}
#[must_use]
pub fn from_projection(projection: &RoadmapProjection) -> Self {
let rows: Vec<PortableInitiativeRow> = projection
.initiatives
.iter()
.map(PortableInitiativeRow::from_row)
.collect();
Self {
format: FORMAT.to_string(),
version: VERSION,
filters: projection.filters.clone(),
total: rows.len(),
rows,
diagnostics: projection
.diagnostics
.iter()
.map(PortableRoadmapDiagnostic::from_roadmap)
.collect(),
extensions: PortableExtensions::new(),
}
}
#[must_use]
pub fn with_remote_mappings(mut self, config: &Config) -> Self {
let targets: Vec<Tool> = [
(config.export.gitlab.is_some(), Tool::GitLab),
(config.export.linear.is_some(), Tool::Linear),
]
.into_iter()
.filter_map(|(configured, tool)| configured.then_some(tool))
.collect();
if targets.is_empty() {
return self;
}
for id in config.initiatives.keys() {
for tool in &targets {
let crate::sync::MappedInitiative::Unsupported(diagnostic) =
crate::sync::map_initiative_to_remote(*tool, id)
else {
continue;
};
self.diagnostics
.push(PortableRoadmapDiagnostic::from_remote(id, &diagnostic));
}
}
self
}
pub fn to_json(&self) -> Result<String> {
serde_json::to_string(self)
.map_err(|error| TixError::Parse(format!("portable roadmap document: {error}")))
}
pub fn to_json_pretty(&self) -> Result<String> {
serde_json::to_string_pretty(self)
.map_err(|error| TixError::Parse(format!("portable roadmap document: {error}")))
}
pub fn from_json(text: &str) -> Result<Self> {
#[derive(Deserialize)]
struct Marker {
#[serde(default)]
format: String,
#[serde(default)]
version: u32,
}
let marker: Marker = serde_json::from_str(text)
.map_err(|error| TixError::Parse(format!("portable roadmap document: {error}")))?;
if marker.format != FORMAT {
return Err(TixError::InvalidInput(format!(
"portable roadmap document has format `{}`, but this build reads `{FORMAT}`",
marker.format
)));
}
if marker.version != VERSION {
return Err(TixError::InvalidInput(format!(
"portable roadmap document declares version {}, but this build reads version \
{VERSION}",
marker.version
)));
}
serde_json::from_str(text)
.map_err(|error| TixError::Parse(format!("portable roadmap document: {error}")))
}
#[must_use]
pub fn diagnostics_for(&self, initiative: &str) -> Vec<&PortableRoadmapDiagnostic> {
self.diagnostics
.iter()
.filter(|diagnostic| diagnostic.initiative.as_deref() == Some(initiative))
.collect()
}
}
impl PortableInitiativeRow {
fn from_row(row: &RoadmapRow) -> Self {
Self {
id: row.id.clone(),
name: row.name.clone(),
outcome: row.outcome.clone(),
owner: row.owner.clone(),
status: row.status.clone(),
priority: row.priority.as_ref().map(ToString::to_string),
labels: row.labels.clone(),
target_date: row.target_date.clone(),
references: row.references.iter().map(reference).collect(),
summary: PortableSummary::from_summary(&row.summary),
diagnostics: row
.diagnostics
.iter()
.map(PortableRoadmapDiagnostic::from_roadmap)
.collect(),
}
}
}
impl PortableSummary {
fn from_summary(summary: &InitiativeSummary) -> Self {
Self {
state: summary.state.as_str().to_string(),
contributions: summary.contributions.iter().map(contribution).collect(),
source_entities: summary.source_entities.clone(),
policy_versions: summary.policy_versions.clone(),
diagnostics: summary
.diagnostics
.iter()
.map(PortableRoadmapDiagnostic::from_roadmap)
.collect(),
}
}
}
fn reference(value: &crate::roadmap::projection::RowReference) -> PortableReference {
PortableReference {
field: value.kind.field().to_string(),
target: value.target.clone(),
label: value.label.clone(),
resolution: value.resolution.as_str().to_string(),
}
}
fn contribution(value: &SummaryContribution) -> PortableContribution {
PortableContribution {
field: value.kind.field().to_string(),
source_entity: value.source_entity(),
target: value.target.clone(),
label: value.label.clone(),
resolution: value.resolution.as_str().to_string(),
value: match &value.progress {
Some(ProgressValue::Project(report)) => Some(PortableValue {
done: report.progress.done,
total: report.progress.total,
}),
Some(ProgressValue::Epic(rollup)) => Some(PortableValue {
done: rollup.progress.done,
total: rollup.progress.total,
}),
None => None,
},
risk_state: value
.risk
.as_ref()
.map(|risk| risk.state.as_str().to_string()),
policy_version: value.policy_version.clone(),
}
}