#![allow(clippy::module_name_repetitions)]
use serde::{Deserialize, Serialize};
use toml::{Table, Value};
use crate::config::{Config, RemoteConfig};
use crate::error::{Result, TixError};
use crate::views::divergence::{ActiveViewMarker, ViewDivergence};
use crate::views::plan::ViewPlan;
use crate::views::registry::{
ProjectDirectory, SavedView, ViewDiagnostic, ViewDiagnosticKind, ViewId, ViewRegistry,
ViewScope,
};
pub const FORMAT: &str = "tix.saved-views";
pub const VERSION: u32 = 1;
pub const REMOTE_TARGETS: &[&str] = &["gitlab", "linear"];
pub const EXECUTABLE_KEYS: &[&str] = &[
"code",
"command",
"dsl",
"exec",
"expr",
"expression",
"jq",
"program",
"query",
"run",
"script",
"shell",
"sql",
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableDiagnostic {
pub kind: String,
#[serde(default)]
pub view_id: Option<String>,
#[serde(default)]
pub field: Option<String>,
pub message: String,
#[serde(default)]
pub remedy: Option<String>,
}
impl PortableDiagnostic {
#[must_use]
pub fn from_diagnostic(diagnostic: &ViewDiagnostic) -> Self {
Self {
kind: diagnostic.kind().as_str().to_string(),
view_id: diagnostic.view_id().map(|id| id.as_str().to_string()),
field: diagnostic.field().map(str::to_string),
message: diagnostic.message().to_string(),
remedy: diagnostic.remedy().map(str::to_string),
}
}
#[must_use]
pub fn kind(&self) -> &str {
&self.kind
}
#[must_use]
pub fn view_id(&self) -> Option<&str> {
self.view_id.as_deref()
}
#[must_use]
pub fn field(&self) -> Option<&str> {
self.field.as_deref()
}
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
#[must_use]
pub fn remedy(&self) -> Option<&str> {
self.remedy.as_deref()
}
#[must_use]
pub fn render(&self) -> String {
match self.field() {
Some(field) => format!("{} [{field}]: {}", self.kind, self.message),
None => format!("{}: {}", self.kind, self.message),
}
}
#[must_use]
fn same_as(&self, other: &Self) -> bool {
self.kind == other.kind
&& self.view_id == other.view_id
&& self.field == other.field
&& self.message == other.message
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableScope {
pub kind: String,
#[serde(default)]
pub project_id: Option<String>,
#[serde(default)]
pub owner: Option<String>,
#[serde(default)]
pub shared_with: Vec<String>,
#[serde(default)]
pub extensions: Table,
#[serde(flatten)]
pub extra: Table,
}
impl PortableScope {
#[must_use]
pub fn from_scope(scope: &ViewScope) -> Self {
Self {
kind: scope.kind.as_str().to_string(),
project_id: scope.project_id.clone(),
owner: scope.owner.clone(),
shared_with: scope.shared_with.clone(),
extensions: scope.extensions.clone(),
extra: Table::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableView {
pub id: String,
pub name: String,
pub scope: PortableScope,
pub resolution: String,
#[serde(default)]
pub plan: Table,
#[serde(default)]
pub normalized_plan: Option<Table>,
#[serde(default)]
pub extensions: Table,
#[serde(default)]
pub diagnostics: Vec<PortableDiagnostic>,
#[serde(flatten)]
pub extra: Table,
}
impl PortableView {
#[must_use]
pub fn from_saved<P: ProjectDirectory + ?Sized>(
view: &SavedView,
projects: &P,
registry_diagnostics: &[ViewDiagnostic],
) -> Self {
let (normalized_plan, plan_diagnostics) = match ViewPlan::from_toml(&view.plan) {
Ok(plan) => (Some(plan.to_toml()), plan.diagnostics()),
Err(refusal) => (None, refusal.diagnostics().to_vec()),
};
let mut classified: Vec<String> = Vec::new();
let mut diagnostics: Vec<ViewDiagnostic> = Vec::new();
let tables = [
("extensions", Some(&view.extensions)),
("scope.extensions", Some(&view.scope.extensions)),
("plan.extensions", Some(&view.plan)),
(
"plan.filter.extensions",
view.plan.get("filter").and_then(Value::as_table),
),
(
"plan.visibility.extensions",
view.plan.get("visibility").and_then(Value::as_table),
),
];
for (path, table) in tables {
let Some(table) = table else { continue };
for key in table.keys() {
let field = format!("{path}.{key}");
if let Some(diagnostic) = classify_key(&field, key, Some(&view.id)) {
classified.push(field);
diagnostics.push(diagnostic);
}
}
}
for diagnostic in registry_diagnostics.iter().chain(plan_diagnostics.iter()) {
let replaces_classified = diagnostic
.field()
.is_some_and(|field| classified.iter().any(|seen| seen.as_str() == field));
if !replaces_classified {
diagnostics.push(diagnostic.clone());
}
}
Self {
id: view.id.as_str().to_string(),
name: view.name.clone(),
scope: PortableScope::from_scope(&view.scope),
resolution: view.scope.resolution(projects).as_str().to_string(),
plan: view.plan.clone(),
normalized_plan,
extensions: view.extensions.clone(),
diagnostics: diagnostics
.iter()
.map(PortableDiagnostic::from_diagnostic)
.collect(),
extra: Table::new(),
}
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn normalized_plan(&self) -> Option<&Table> {
self.normalized_plan.as_ref()
}
#[must_use]
pub fn diagnostics(&self) -> &[PortableDiagnostic] {
&self.diagnostics
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableMarker {
pub view_id: String,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub applied_at: Option<String>,
#[serde(default)]
pub applied_plan: Table,
#[serde(flatten)]
pub extra: Table,
}
impl PortableMarker {
#[must_use]
pub fn from_marker(marker: &ActiveViewMarker) -> Self {
Self {
view_id: marker.view_id().as_str().to_string(),
source: marker.source().map(|surface| surface.as_str().to_string()),
applied_at: marker.applied_at().map(str::to_string),
applied_plan: marker.applied_plan().to_toml(),
extra: Table::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortableDifference {
pub field: String,
#[serde(default)]
pub saved: Option<String>,
#[serde(default)]
pub live: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableDivergence {
#[serde(default)]
pub view_id: Option<String>,
#[serde(default)]
pub saved_plan: Option<Table>,
#[serde(default)]
pub live_plan: Table,
#[serde(default)]
pub differences: Vec<PortableDifference>,
pub in_sync: bool,
#[serde(default)]
pub diagnostics: Vec<PortableDiagnostic>,
#[serde(flatten)]
pub extra: Table,
}
impl PortableDivergence {
#[must_use]
pub fn from_divergence(divergence: &ViewDivergence) -> Self {
Self {
view_id: divergence.view_id().map(|id| id.as_str().to_string()),
saved_plan: divergence.saved_plan().map(ViewPlan::to_toml),
live_plan: divergence.live_plan().to_toml(),
differences: divergence
.differences()
.iter()
.map(|difference| PortableDifference {
field: difference.field().to_string(),
saved: difference.saved().map(str::to_string),
live: difference.live().map(str::to_string),
})
.collect(),
in_sync: divergence.in_sync(),
diagnostics: divergence
.diagnostics()
.iter()
.map(PortableDiagnostic::from_diagnostic)
.collect(),
extra: Table::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableRemoteTarget {
pub name: String,
#[serde(default)]
pub fields: Table,
#[serde(flatten)]
pub extra: Table,
}
impl PortableRemoteTarget {
#[must_use]
pub fn new(name: &str, remote: &RemoteConfig) -> Self {
let mut fields = Table::new();
for (key, value) in [
("base_url", &remote.base_url),
("project", &remote.project),
("team", &remote.team),
] {
if let Some(value) = value {
fields.insert(key.to_string(), Value::String(value.clone()));
}
}
Self {
name: name.to_string(),
fields,
extra: Table::new(),
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PortableRemoteTargets {
#[serde(default)]
pub targets: Vec<PortableRemoteTarget>,
#[serde(flatten)]
pub extra: Table,
}
impl PortableRemoteTargets {
#[must_use]
pub fn from_config(config: &Config) -> Self {
let mut targets = Vec::new();
if let Some(gitlab) = &config.export.gitlab {
targets.push(PortableRemoteTarget::new("gitlab", gitlab));
}
if let Some(linear) = &config.export.linear {
targets.push(PortableRemoteTarget::new("linear", linear));
}
Self {
targets,
extra: Table::new(),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.targets.is_empty()
}
#[must_use]
pub fn targets(&self) -> &[PortableRemoteTarget] {
&self.targets
}
#[must_use]
pub fn contains(&self, name: &str) -> bool {
self.targets.iter().any(|target| target.name == name)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PortableDocument {
pub format: String,
pub version: u32,
#[serde(default)]
pub views: Vec<PortableView>,
#[serde(default)]
pub active: Option<PortableMarker>,
#[serde(default)]
pub divergence: Option<PortableDivergence>,
#[serde(default)]
pub remote: PortableRemoteTargets,
#[serde(flatten)]
pub extensions: Table,
#[serde(default)]
pub diagnostics: Vec<PortableDiagnostic>,
}
impl Default for PortableDocument {
fn default() -> Self {
Self::empty()
}
}
impl PortableDocument {
#[must_use]
pub fn empty() -> Self {
Self {
format: FORMAT.to_string(),
version: VERSION,
views: Vec::new(),
active: None,
divergence: None,
remote: PortableRemoteTargets::default(),
extensions: Table::new(),
diagnostics: Vec::new(),
}
}
#[must_use]
pub fn from_registry<P: ProjectDirectory + ?Sized>(
registry: &ViewRegistry,
projects: &P,
active: Option<&ActiveViewMarker>,
divergence: Option<&ViewDivergence>,
) -> Self {
let registry_diagnostics = registry.diagnostics(projects);
let views = registry
.iter()
.map(|view| {
let own: Vec<ViewDiagnostic> = registry_diagnostics
.iter()
.filter(|diagnostic| diagnostic.view_id() == Some(&view.id))
.cloned()
.collect();
PortableView::from_saved(view, projects, &own)
})
.collect();
let mut document = Self {
views,
active: active.map(PortableMarker::from_marker),
divergence: divergence.map(PortableDivergence::from_divergence),
..Self::empty()
};
document.refresh_diagnostics();
document
}
#[must_use]
pub fn with_remote(mut self, remote: PortableRemoteTargets) -> Self {
self.remote = remote;
self
}
#[must_use]
pub fn format(&self) -> &str {
&self.format
}
#[must_use]
pub const fn version(&self) -> u32 {
self.version
}
#[must_use]
pub fn views(&self) -> &[PortableView] {
&self.views
}
#[must_use]
pub fn view(&self, id: &str) -> Option<&PortableView> {
self.views.iter().find(|view| view.id == id)
}
#[must_use]
pub fn active(&self) -> Option<&PortableMarker> {
self.active.as_ref()
}
#[must_use]
pub fn divergence(&self) -> Option<&PortableDivergence> {
self.divergence.as_ref()
}
#[must_use]
pub const fn remote(&self) -> &PortableRemoteTargets {
&self.remote
}
#[must_use]
pub const fn extensions(&self) -> &Table {
&self.extensions
}
#[must_use]
pub fn diagnostics(&self) -> &[PortableDiagnostic] {
&self.diagnostics
}
#[must_use]
pub fn all_diagnostics(&self) -> Vec<&PortableDiagnostic> {
let mut all: Vec<&PortableDiagnostic> = self.diagnostics.iter().collect();
for view in &self.views {
all.extend(view.diagnostics.iter());
}
all
}
pub fn to_json(&self) -> Result<String> {
serde_json::to_string(self)
.map_err(|error| TixError::Parse(format!("portable view document: {error}")))
}
pub fn to_json_pretty(&self) -> Result<String> {
serde_json::to_string_pretty(self)
.map_err(|error| TixError::Parse(format!("portable view document: {error}")))
}
pub fn from_json(text: &str) -> Result<Self> {
let mut document: Self = serde_json::from_str(text)
.map_err(|error| TixError::Parse(format!("portable view document: {error}")))?;
document.refresh_diagnostics();
Ok(document)
}
pub fn refresh_diagnostics(&mut self) {
let derived = self.derived_diagnostics();
let mut merged: Vec<PortableDiagnostic> =
Vec::with_capacity(self.diagnostics.len() + derived.len());
for diagnostic in self.diagnostics.iter().chain(derived.iter()) {
if !merged.iter().any(|seen| seen.same_as(diagnostic)) {
merged.push(diagnostic.clone());
}
}
self.diagnostics = merged;
}
#[must_use]
fn derived_diagnostics(&self) -> Vec<PortableDiagnostic> {
let mut diagnostics: Vec<ViewDiagnostic> = Vec::new();
if self.format != FORMAT {
diagnostics.push(
ViewDiagnostic::new(
ViewDiagnosticKind::UnknownMetadata,
format!(
"portable document declares format `{}`, but this build writes and reads \
`{FORMAT}`; the declared value is preserved verbatim",
self.format
),
)
.with_field("format")
.with_remedy(
"read the document with the tool that wrote it, or migrate the format",
),
);
}
if self.version != VERSION {
diagnostics.push(
ViewDiagnostic::new(
ViewDiagnosticKind::UnknownMetadata,
format!(
"portable document declares version {}, but this build writes and reads \
version {VERSION}; fields this build does not interpret are preserved \
verbatim",
self.version
),
)
.with_field("version"),
);
}
for key in self.extensions.keys() {
diagnostics.push(match classify_key(key, key, None) {
Some(diagnostic) => diagnostic,
None => unknown_metadata(key),
});
}
diagnostics
.iter()
.map(PortableDiagnostic::from_diagnostic)
.collect()
}
}
fn classify_key(field: &str, key: &str, view_id: Option<&ViewId>) -> Option<ViewDiagnostic> {
let (message, remedy) = if REMOTE_TARGETS.contains(&key) {
(
format!(
"`{field}` names the remote export target `{key}`; remote namespaces are kept \
separate from local view data and the value is preserved verbatim"
),
"rename the key; remote export configuration lives under `[export.*]`, never on a view"
.to_string(),
)
} else if EXECUTABLE_KEYS.contains(&key) {
(
format!(
"`{field}` looks like executable or query-payload content; this build never \
interprets it and preserves the value verbatim"
),
"views are data-only; remove the key, or express the intent with a supported plan field"
.to_string(),
)
} else {
return None;
};
let diagnostic = match view_id {
Some(id) => ViewDiagnostic::for_view(ViewDiagnosticKind::UnknownMetadata, id, message),
None => ViewDiagnostic::new(ViewDiagnosticKind::UnknownMetadata, message),
};
Some(diagnostic.with_field(field.to_string()).with_remedy(remedy))
}
fn unknown_metadata(field: &str) -> ViewDiagnostic {
ViewDiagnostic::new(
ViewDiagnosticKind::UnknownMetadata,
format!("`{field}` is not interpreted by this build; the value is preserved verbatim"),
)
.with_field(field.to_string())
}