use std::collections::BTreeSet;
use std::fmt;
use std::hash::{BuildHasher, Hash, Hasher};
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::error::TixError;
pub type ViewResult<T> = std::result::Result<T, ViewRefusal>;
pub type ViewWriteResult<T> = std::result::Result<T, ViewWriteError>;
#[derive(Debug)]
pub enum ViewWriteError {
Refused(ViewRefusal),
Failed(TixError),
}
impl ViewWriteError {
#[must_use]
pub fn refusal(&self) -> Option<&ViewRefusal> {
match self {
Self::Refused(refusal) => Some(refusal),
Self::Failed(_) => None,
}
}
#[must_use]
pub fn failure(&self) -> Option<&TixError> {
match self {
Self::Refused(_) => None,
Self::Failed(error) => Some(error),
}
}
}
impl From<ViewRefusal> for ViewWriteError {
fn from(refusal: ViewRefusal) -> Self {
Self::Refused(refusal)
}
}
impl From<TixError> for ViewWriteError {
fn from(error: TixError) -> Self {
Self::Failed(error)
}
}
impl fmt::Display for ViewWriteError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Refused(refusal) => write!(f, "{refusal}"),
Self::Failed(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for ViewWriteError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ViewDiagnosticKind {
InvalidView,
InvalidFilter,
InvalidSort,
InvalidGroup,
InvalidVisibility,
ScopeConflict,
UnknownView,
UnknownMetadata,
}
impl ViewDiagnosticKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::InvalidView => "invalid_view",
Self::InvalidFilter => "invalid_filter",
Self::InvalidSort => "invalid_sort",
Self::InvalidGroup => "invalid_group",
Self::InvalidVisibility => "invalid_visibility",
Self::ScopeConflict => "scope_conflict",
Self::UnknownView => "unknown_view",
Self::UnknownMetadata => "unknown_metadata",
}
}
}
impl fmt::Display for ViewDiagnosticKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ViewDiagnostic {
kind: ViewDiagnosticKind,
view_id: Option<ViewId>,
field: Option<String>,
message: String,
remedy: Option<String>,
}
impl ViewDiagnostic {
#[must_use]
pub fn for_view(
kind: ViewDiagnosticKind,
view_id: &ViewId,
message: impl Into<String>,
) -> Self {
Self {
kind,
view_id: Some(view_id.clone()),
field: None,
message: message.into(),
remedy: None,
}
}
#[must_use]
pub fn new(kind: ViewDiagnosticKind, message: impl Into<String>) -> Self {
Self {
kind,
view_id: None,
field: None,
message: message.into(),
remedy: None,
}
}
#[must_use]
pub fn with_field(mut self, field: impl Into<String>) -> Self {
self.field = Some(field.into());
self
}
#[must_use]
pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
self.remedy = Some(remedy.into());
self
}
#[must_use]
pub const fn kind(&self) -> ViewDiagnosticKind {
self.kind
}
#[must_use]
pub fn view_id(&self) -> Option<&ViewId> {
self.view_id.as_ref()
}
#[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()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ViewRefusal {
diagnostics: Vec<ViewDiagnostic>,
}
impl ViewRefusal {
#[must_use]
pub fn from_diagnostics(diagnostics: Vec<ViewDiagnostic>) -> Self {
assert!(
!diagnostics.is_empty(),
"a refusal carries at least one diagnostic"
);
Self { diagnostics }
}
#[must_use]
pub fn from_diagnostic(diagnostic: ViewDiagnostic) -> Self {
Self {
diagnostics: vec![diagnostic],
}
}
#[must_use]
pub fn diagnostics(&self) -> &[ViewDiagnostic] {
&self.diagnostics
}
#[must_use]
pub fn kind(&self) -> ViewDiagnosticKind {
self.diagnostics[0].kind()
}
#[must_use]
pub fn message(&self) -> &str {
self.diagnostics[0].message()
}
#[must_use]
pub fn render(&self) -> String {
crate::error::render_diagnostics(
"E-tix-views",
self.diagnostics
.iter()
.map(|diagnostic| (diagnostic.kind().as_str(), diagnostic.message())),
)
}
}
impl From<ViewDiagnostic> for ViewRefusal {
fn from(diagnostic: ViewDiagnostic) -> Self {
Self::from_diagnostic(diagnostic)
}
}
impl fmt::Display for ViewRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.render())
}
}
impl std::error::Error for ViewRefusal {}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ViewId(String);
impl ViewId {
pub const MAX_LEN: usize = 128;
pub fn new(raw: &str) -> ViewResult<Self> {
if raw.trim().is_empty() {
return Err(ViewDiagnostic::new(
ViewDiagnosticKind::InvalidView,
"a view id must not be blank",
)
.with_field("id")
.with_remedy(
"choose an id such as `platform-active`, or omit it and let tix generate one",
)
.into());
}
if raw.trim() != raw {
return Err(ViewDiagnostic::new(
ViewDiagnosticKind::InvalidView,
format!("view id `{raw}` has leading or trailing whitespace"),
)
.with_field("id")
.into());
}
if raw.chars().count() > Self::MAX_LEN {
return Err(ViewDiagnostic::new(
ViewDiagnosticKind::InvalidView,
format!(
"view id is {} characters; the limit is {}",
raw.chars().count(),
Self::MAX_LEN
),
)
.with_field("id")
.into());
}
if let Some(bad) = raw.chars().find(|c| is_structural(*c)) {
return Err(ViewDiagnostic::new(
ViewDiagnosticKind::InvalidView,
format!("view id `{raw}` contains `{bad}`"),
)
.with_field("id")
.with_remedy("use letters, digits, `-`, `_`, or `:`")
.into());
}
Ok(Self(raw.to_string()))
}
#[must_use]
pub fn generate() -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
std::process::id().hash(&mut hasher);
COUNTER.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_nanos())
.hash(&mut hasher);
let bits = hasher.finish();
Self(format!("v-{bits:016x}"))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
fn is_structural(c: char) -> bool {
c.is_whitespace()
|| matches!(c, '.' | '"' | '\'' | '[' | ']' | '#' | '=' | '\\')
|| c.is_control()
}
impl TryFrom<String> for ViewId {
type Error = ViewRefusal;
fn try_from(raw: String) -> ViewResult<Self> {
Self::new(&raw)
}
}
impl From<ViewId> for String {
fn from(id: ViewId) -> Self {
id.0
}
}
impl AsRef<str> for ViewId {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for ViewId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ViewScopeKind {
#[default]
Repository,
Project,
UserShare,
}
impl ViewScopeKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Repository => "repository",
Self::Project => "project",
Self::UserShare => "user_share",
}
}
#[must_use]
pub const fn precedence(self) -> u8 {
match self {
Self::Repository => 1,
Self::Project => 2,
Self::UserShare => 3,
}
}
}
impl fmt::Display for ViewScopeKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScopeResolution {
Resolved,
Missing,
Conflict,
}
impl ScopeResolution {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Resolved => "resolved",
Self::Missing => "missing",
Self::Conflict => "conflict",
}
}
}
impl fmt::Display for ScopeResolution {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
pub trait ProjectDirectory {
fn contains(&self, project_id: &str) -> bool;
}
impl<T> ProjectDirectory for IndexMap<String, T> {
fn contains(&self, project_id: &str) -> bool {
self.contains_key(project_id)
}
}
impl ProjectDirectory for BTreeSet<String> {
fn contains(&self, project_id: &str) -> bool {
self.contains(project_id)
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ViewScope {
pub kind: ViewScopeKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub shared_with: Vec<String>,
#[serde(flatten)]
pub extensions: toml::Table,
}
impl ViewScope {
#[must_use]
pub fn repository() -> Self {
Self::default()
}
#[must_use]
pub fn project(project_id: impl Into<String>) -> Self {
Self {
kind: ViewScopeKind::Project,
project_id: Some(project_id.into()),
..Self::default()
}
}
#[must_use]
pub fn user_share(owner: Option<String>, shared_with: Vec<String>) -> Self {
Self {
kind: ViewScopeKind::UserShare,
project_id: None,
owner,
shared_with,
extensions: toml::Table::new(),
}
}
#[must_use]
pub fn conflicts(&self) -> Vec<ViewDiagnostic> {
let mut diagnostics = Vec::new();
let mut conflict = |field: &str, message: String, remedy: &str| {
diagnostics.push(
ViewDiagnostic::new(ViewDiagnosticKind::ScopeConflict, message)
.with_field(format!("scope.{field}"))
.with_remedy(remedy),
);
};
match self.kind {
ViewScopeKind::Repository => {
if self.project_id.is_some() {
conflict(
"project_id",
"a `repository` scope must not name a project".to_string(),
"use `kind = \"project\"` for a project scope, or drop `project_id`",
);
}
}
ViewScopeKind::Project => match self.project_id.as_deref() {
None => conflict(
"project_id",
"a `project` scope requires a project id".to_string(),
"set `project_id`, or use `kind = \"repository\"`",
),
Some(project_id) if project_id.trim().is_empty() => conflict(
"project_id",
"a `project` scope has a blank project id".to_string(),
"set `project_id`, or use `kind = \"repository\"`",
),
Some(project_id) if project_id.trim() != project_id => conflict(
"project_id",
format!("project id `{project_id}` has surrounding whitespace"),
"remove the whitespace",
),
Some(_) => {}
},
ViewScopeKind::UserShare => {
if self.project_id.is_some() {
conflict(
"project_id",
"a `user_share` scope must not name a project".to_string(),
"drop `project_id`, or use `kind = \"project\"`",
);
}
if self.owner.is_none() && self.shared_with.is_empty() {
conflict(
"owner",
"a `user_share` scope must name an owner or at least one shared-with identity"
.to_string(),
"set `owner` or `shared_with`, or use `kind = \"repository\"`",
);
}
}
}
if self.kind != ViewScopeKind::UserShare {
if self.owner.is_some() {
conflict(
"owner",
format!("a `{}` scope must not carry an owner", self.kind.as_str()),
"owner/shared-with metadata belongs to `kind = \"user_share\"`",
);
}
if !self.shared_with.is_empty() {
conflict(
"shared_with",
format!(
"a `{}` scope must not carry shared-with identities",
self.kind.as_str()
),
"owner/shared-with metadata belongs to `kind = \"user_share\"`",
);
}
}
if let Some(owner) = self.owner.as_deref()
&& owner.trim().is_empty()
{
conflict(
"owner",
"`owner` is present but blank".to_string(),
"remove `owner`, or name the identity it belongs to",
);
}
let mut seen: Vec<&str> = Vec::new();
for identity in &self.shared_with {
if identity.trim().is_empty() {
conflict(
"shared_with",
"`shared_with` contains a blank identity".to_string(),
"remove the blank entry",
);
} else if seen.contains(&identity.as_str()) {
conflict(
"shared_with",
format!("`shared_with` names `{identity}` twice"),
"keep one entry per identity",
);
} else {
seen.push(identity.as_str());
}
}
diagnostics
}
#[must_use]
pub fn resolution<P: ProjectDirectory + ?Sized>(&self, projects: &P) -> ScopeResolution {
if !self.conflicts().is_empty() {
return ScopeResolution::Conflict;
}
match self.kind {
ViewScopeKind::Project => {
let known = self
.project_id
.as_deref()
.is_some_and(|project_id| projects.contains(project_id.trim()));
if known {
ScopeResolution::Resolved
} else {
ScopeResolution::Missing
}
}
ViewScopeKind::Repository | ViewScopeKind::UserShare => ScopeResolution::Resolved,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SavedView {
pub id: ViewId,
pub name: String,
#[serde(default)]
pub scope: ViewScope,
#[serde(default, skip_serializing_if = "toml::Table::is_empty")]
pub plan: toml::Table,
#[serde(flatten)]
pub extensions: toml::Table,
}
impl SavedView {
pub fn new(id: ViewId, name: &str, scope: ViewScope) -> ViewResult<Self> {
let view = Self {
id,
name: name.trim().to_string(),
scope,
plan: toml::Table::new(),
extensions: toml::Table::new(),
};
view.check_structure()?;
Ok(view)
}
#[must_use]
pub fn with_plan(mut self, plan: toml::Table) -> Self {
self.plan = plan;
self
}
pub fn check_structure(&self) -> ViewResult<()> {
let mut diagnostics = Vec::new();
if self.name.trim().is_empty() {
diagnostics.push(
ViewDiagnostic::for_view(
ViewDiagnosticKind::InvalidView,
&self.id,
format!("view `{}` has no name", self.id),
)
.with_field("name")
.with_remedy("set `name`; the id is not a substitute for a label"),
);
}
diagnostics.extend(
self.scope
.conflicts()
.into_iter()
.map(|diagnostic| attach_view(diagnostic, &self.id)),
);
if diagnostics.is_empty() {
Ok(())
} else {
Err(ViewRefusal::from_diagnostics(diagnostics))
}
}
fn unknown_metadata(&self) -> Vec<ViewDiagnostic> {
let mut diagnostics = Vec::new();
for key in self.extensions.keys() {
diagnostics.push(
ViewDiagnostic::for_view(
ViewDiagnosticKind::UnknownMetadata,
&self.id,
format!(
"view `{}` carries an unknown field `{key}`, preserved as written",
self.id
),
)
.with_field(format!("extensions.{key}")),
);
}
for key in self.scope.extensions.keys() {
diagnostics.push(
ViewDiagnostic::for_view(
ViewDiagnosticKind::UnknownMetadata,
&self.id,
format!(
"view `{}` scope carries an unknown field `{key}`, preserved as written",
self.id
),
)
.with_field(format!("scope.extensions.{key}")),
);
}
diagnostics
}
}
fn attach_view(diagnostic: ViewDiagnostic, view_id: &ViewId) -> ViewDiagnostic {
let mut out = ViewDiagnostic::for_view(diagnostic.kind(), view_id, diagnostic.message());
if let Some(field) = diagnostic.field() {
out = out.with_field(field.to_string());
}
if let Some(remedy) = diagnostic.remedy() {
out = out.with_remedy(remedy.to_string());
}
out
}
fn selected<'a, P: ProjectDirectory + ?Sized>(
view: &'a SavedView,
projects: &P,
) -> ViewResult<&'a SavedView> {
match view.scope.resolution(projects) {
ScopeResolution::Resolved => Ok(view),
ScopeResolution::Missing => Err(missing_project(view).into()),
ScopeResolution::Conflict => {
let diagnostics: Vec<ViewDiagnostic> = view
.scope
.conflicts()
.into_iter()
.map(|diagnostic| attach_view(diagnostic, &view.id))
.collect();
Err(ViewRefusal::from_diagnostics(diagnostics))
}
}
}
fn missing_project(view: &SavedView) -> ViewDiagnostic {
ViewDiagnostic::for_view(
ViewDiagnosticKind::ScopeConflict,
&view.id,
format!(
"view `{}` is scoped to project `{}`, which is not in the project registry",
view.id,
view.scope.project_id.as_deref().unwrap_or("")
),
)
.with_field("scope.project_id")
.with_remedy("create the project, or re-scope the view to the repository")
}
#[derive(Debug, Clone, PartialEq)]
pub struct ViewRegistry {
views: IndexMap<String, SavedView>,
}
impl Default for ViewRegistry {
fn default() -> Self {
Self::new()
}
}
impl ViewRegistry {
#[must_use]
pub fn new() -> Self {
Self {
views: IndexMap::new(),
}
}
#[must_use]
pub fn len(&self) -> usize {
self.views.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.views.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &SavedView> {
self.views.values()
}
#[must_use]
pub fn get(&self, id: &ViewId) -> Option<&SavedView> {
self.views.get(id.as_str())
}
#[must_use]
pub fn contains(&self, id: &ViewId) -> bool {
self.views.contains_key(id.as_str())
}
#[must_use]
pub fn allocate_id(&self) -> ViewId {
loop {
let candidate = ViewId::generate();
if !self.contains(&candidate) {
return candidate;
}
}
}
pub fn insert(&mut self, view: SavedView) -> ViewResult<()> {
if let Some(existing) = self.views.get(view.id.as_str()) {
return Err(ViewDiagnostic::for_view(
ViewDiagnosticKind::InvalidView,
&view.id,
format!(
"view id `{}` is already taken by `{}`; ids are never reused",
view.id, existing.name
),
)
.with_field("id")
.with_remedy("choose a different id, or edit the view that owns it")
.into());
}
view.check_structure()?;
self.views.insert(view.id.as_str().to_string(), view);
Ok(())
}
pub fn rename(&mut self, id: &ViewId, name: &str) -> ViewResult<()> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(ViewDiagnostic::for_view(
ViewDiagnosticKind::InvalidView,
id,
format!("view `{id}` cannot be renamed to a blank name"),
)
.with_field("name")
.into());
}
let view = self.views.get_mut(id.as_str()).ok_or_else(|| {
ViewDiagnostic::new(
ViewDiagnosticKind::UnknownView,
format!("no saved view with id `{id}`"),
)
.with_field("id")
})?;
view.name = trimmed.to_string();
Ok(())
}
pub fn set_scope(&mut self, id: &ViewId, scope: ViewScope) -> ViewResult<()> {
let diagnostics: Vec<ViewDiagnostic> = scope
.conflicts()
.into_iter()
.map(|diagnostic| attach_view(diagnostic, id))
.collect();
if !diagnostics.is_empty() {
return Err(ViewRefusal::from_diagnostics(diagnostics));
}
let view = self.views.get_mut(id.as_str()).ok_or_else(|| {
ViewDiagnostic::new(
ViewDiagnosticKind::UnknownView,
format!("no saved view with id `{id}`"),
)
.with_field("id")
})?;
view.scope = scope;
Ok(())
}
pub fn set_plan(&mut self, id: &ViewId, plan: toml::Table) -> ViewResult<()> {
let view = self.views.get_mut(id.as_str()).ok_or_else(|| {
ViewDiagnostic::new(
ViewDiagnosticKind::UnknownView,
format!("no saved view with id `{id}`"),
)
.with_field("id")
})?;
view.plan = plan;
Ok(())
}
pub fn remove(&mut self, id: &ViewId) -> Option<SavedView> {
self.views.shift_remove(id.as_str())
}
pub fn select<P: ProjectDirectory + ?Sized>(
&self,
reference: &str,
projects: &P,
) -> ViewResult<&SavedView> {
let reference = reference.trim();
if let Some(view) = self.views.get(reference) {
return selected(view, projects);
}
let mut candidates: Vec<&SavedView> = self
.views
.values()
.filter(|view| view.name == reference)
.collect();
if candidates.is_empty() {
return Err(ViewDiagnostic::new(
ViewDiagnosticKind::UnknownView,
format!("no saved view matches `{reference}`"),
)
.with_field("id")
.with_remedy("list the saved views and select one by id")
.into());
}
candidates.sort_by(|left, right| left.id.cmp(&right.id));
let best = candidates
.iter()
.map(|view| view.scope.kind.precedence())
.max()
.unwrap_or(ViewScopeKind::Repository.precedence());
let winners: Vec<&SavedView> = candidates
.into_iter()
.filter(|view| view.scope.kind.precedence() == best)
.collect();
if winners.len() > 1 {
let ids = winners
.iter()
.map(|view| format!("`{}`", view.id))
.collect::<Vec<_>>()
.join(", ");
let kinds = winners
.iter()
.map(|view| view.scope.kind.as_str())
.collect::<Vec<_>>()
.join(", ");
return Err(ViewDiagnostic::new(
ViewDiagnosticKind::ScopeConflict,
format!(
"view name `{reference}` is ambiguous: {ids} are all {} scope",
kinds
),
)
.with_field("name")
.with_remedy("select the view by its stable id")
.into());
}
selected(winners[0], projects)
}
pub fn check_structure(&self) -> ViewResult<()> {
let mut diagnostics = Vec::new();
for view in self.views.values() {
if let Err(refusal) = view.check_structure() {
diagnostics.extend(refusal.diagnostics().iter().cloned());
}
}
if diagnostics.is_empty() {
Ok(())
} else {
Err(ViewRefusal::from_diagnostics(diagnostics))
}
}
pub fn check<P: ProjectDirectory + ?Sized>(&self, projects: &P) -> ViewResult<()> {
let mut diagnostics = Vec::new();
if let Err(refusal) = self.check_structure() {
diagnostics.extend(refusal.diagnostics().iter().cloned());
}
for view in self.views.values() {
if let ScopeResolution::Missing = view.scope.resolution(projects) {
diagnostics.push(missing_project(view));
}
}
if diagnostics.is_empty() {
Ok(())
} else {
Err(ViewRefusal::from_diagnostics(diagnostics))
}
}
#[must_use]
pub fn diagnostics<P: ProjectDirectory + ?Sized>(&self, projects: &P) -> Vec<ViewDiagnostic> {
let mut diagnostics = Vec::new();
for view in self.views.values() {
let conflicts = view.scope.conflicts();
if conflicts.is_empty() {
if let ScopeResolution::Missing = view.scope.resolution(projects) {
diagnostics.push(missing_project(view));
}
} else {
diagnostics.extend(
conflicts
.into_iter()
.map(|diagnostic| attach_view(diagnostic, &view.id)),
);
}
diagnostics.extend(view.unknown_metadata());
}
diagnostics
}
pub fn commit<P: ProjectDirectory + ?Sized>(
&self,
config: &mut Config,
projects: &P,
path: &Path,
) -> ViewWriteResult<()> {
self.check(projects).map_err(ViewWriteError::Refused)?;
let mut candidate = config.clone();
candidate.views = self.clone();
candidate.validate().map_err(ViewWriteError::Failed)?;
candidate.save(path).map_err(ViewWriteError::Failed)?;
*config = candidate;
Ok(())
}
pub(crate) fn restore_declaration_order(&mut self, contents: &str) {
crate::config::restore_section_order(&mut self.views, contents, "views");
}
}
impl Serialize for ViewRegistry {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.views.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for ViewRegistry {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let stored = IndexMap::<String, SavedView>::deserialize(deserializer)?;
let mut registry = Self::new();
for (key, view) in stored {
if key != view.id.as_str() {
return Err(serde::de::Error::custom(format!(
"the `[views.{key}]` record declares id `{}`: the table key is the stable \
identity and the two must agree",
view.id.as_str()
)));
}
registry.views.insert(key, view);
}
Ok(registry)
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
fn projects(ids: &[&str]) -> BTreeSet<String> {
ids.iter().map(|id| (*id).to_string()).collect()
}
fn view(id: &str, name: &str, scope: ViewScope) -> SavedView {
SavedView::new(ViewId::new(id).unwrap(), name, scope).unwrap()
}
#[test]
fn blank_and_ill_formed_ids_are_refused() {
assert!(ViewId::new("").is_err());
assert!(ViewId::new(" ").is_err());
assert!(ViewId::new(" padded").is_err());
assert!(ViewId::new("a.b").is_err());
assert!(ViewId::new("a[b]").is_err());
assert!(ViewId::new("a b").is_err());
assert!(ViewId::new(&"x".repeat(ViewId::MAX_LEN + 1)).is_err());
assert_eq!(
ViewId::new("platform-active").unwrap().as_str(),
"platform-active"
);
}
#[test]
fn generated_ids_are_unique_and_well_formed() {
let first = ViewId::generate();
let second = ViewId::generate();
assert_ne!(first, second);
assert!(first.as_str().starts_with("v-"));
assert_eq!(first.as_str().len(), 18);
assert!(ViewId::new(first.as_str()).is_ok());
}
#[test]
fn allocate_id_skips_stored_ids() {
let mut registry = ViewRegistry::new();
registry
.insert(view("taken", "Taken", ViewScope::repository()))
.unwrap();
for _ in 0..32 {
assert!(!registry.contains(®istry.allocate_id()));
}
}
#[test]
fn duplicate_id_is_a_refusal_not_an_overwrite() {
let mut registry = ViewRegistry::new();
registry
.insert(view("platform", "Platform", ViewScope::repository()))
.unwrap();
let refusal = registry
.insert(view("platform", "Something else", ViewScope::repository()))
.unwrap_err();
assert_eq!(refusal.kind(), ViewDiagnosticKind::InvalidView);
assert_eq!(
registry
.get(&ViewId::new("platform").unwrap())
.unwrap()
.name,
"Platform",
"an existing definition is never overwritten by a duplicate id"
);
}
#[test]
fn blank_names_are_refused_on_insert_and_rename() {
let mut registry = ViewRegistry::new();
let refusal = SavedView::new(
ViewId::new("platform").unwrap(),
" ",
ViewScope::repository(),
)
.unwrap_err();
assert_eq!(refusal.kind(), ViewDiagnosticKind::InvalidView);
registry
.insert(view("platform", "Platform", ViewScope::repository()))
.unwrap();
let id = ViewId::new("platform").unwrap();
assert!(registry.rename(&id, " ").is_err());
registry.rename(&id, " Platform active ").unwrap();
assert_eq!(registry.get(&id).unwrap().name, "Platform active");
}
#[test]
fn scope_kinds_rank_by_specificity() {
assert!(ViewScopeKind::UserShare.precedence() > ViewScopeKind::Project.precedence());
assert!(ViewScopeKind::Project.precedence() > ViewScopeKind::Repository.precedence());
}
#[test]
fn repository_scope_must_not_name_a_project() {
let scope = ViewScope {
kind: ViewScopeKind::Repository,
project_id: Some("p-platform".into()),
..ViewScope::default()
};
let conflicts = scope.conflicts();
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].field(), Some("scope.project_id"));
assert_eq!(
scope.resolution(&projects(&["p-platform"])),
ScopeResolution::Conflict
);
}
#[test]
fn project_scope_requires_a_project_and_resolves_through_the_directory() {
let empty = ViewScope::project(" ");
assert_eq!(empty.conflicts().len(), 1);
let scope = ViewScope::project("p-platform");
assert_eq!(
scope.resolution(&projects(&["p-platform"])),
ScopeResolution::Resolved
);
assert_eq!(scope.resolution(&projects(&[])), ScopeResolution::Missing);
}
#[test]
fn user_share_scope_needs_owner_or_shared_with_and_is_metadata_only() {
assert_eq!(ViewScope::user_share(None, vec![]).conflicts().len(), 1);
let scope = ViewScope::user_share(Some("alice".into()), vec!["bob".into()]);
assert_eq!(
scope.conflicts(),
[] as [crate::views::registry::ViewDiagnostic; 0]
);
assert_eq!(scope.resolution(&projects(&[])), ScopeResolution::Resolved);
}
#[test]
fn user_share_scope_must_not_name_a_project() {
let scope = ViewScope {
kind: ViewScopeKind::UserShare,
project_id: Some("p-platform".into()),
owner: Some("alice".into()),
..ViewScope::default()
};
let conflicts = scope.conflicts();
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].field(), Some("scope.project_id"));
assert_eq!(
scope.resolution(&projects(&["p-platform"])),
ScopeResolution::Conflict
);
}
#[test]
fn share_metadata_on_a_non_share_scope_is_a_conflict() {
let scope = ViewScope {
kind: ViewScopeKind::Repository,
owner: Some("alice".into()),
shared_with: vec!["bob".into()],
..ViewScope::default()
};
let conflicts = scope.conflicts();
let fields: Vec<Option<&str>> = conflicts.iter().map(|d| d.field()).collect();
assert_eq!(fields, vec![Some("scope.owner"), Some("scope.shared_with")]);
}
#[test]
fn repeated_and_blank_shared_with_identities_are_refused() {
let scope = ViewScope::user_share(Some("alice".into()), vec!["bob".into(), "bob".into()]);
assert_eq!(scope.conflicts().len(), 1);
let scope = ViewScope::user_share(Some("alice".into()), vec![" ".into()]);
assert_eq!(scope.conflicts().len(), 1);
}
#[test]
fn select_prefers_the_id_and_then_the_most_specific_scope() {
let mut registry = ViewRegistry::new();
registry
.insert(view("repo", "Active", ViewScope::repository()))
.unwrap();
registry
.insert(view("proj", "Active", ViewScope::project("p-platform")))
.unwrap();
let known = projects(&["p-platform"]);
assert_eq!(registry.select("proj", &known).unwrap().id.as_str(), "proj");
assert_eq!(
registry.select("Active", &known).unwrap().id.as_str(),
"proj"
);
}
#[test]
fn user_share_tie_is_a_deterministic_conflict() {
let mut registry = ViewRegistry::new();
registry
.insert(view(
"zoe",
"Mine",
ViewScope::user_share(Some("zoe".into()), vec![]),
))
.unwrap();
registry
.insert(view(
"alice",
"Mine",
ViewScope::user_share(Some("alice".into()), vec![]),
))
.unwrap();
let known = projects(&[]);
let first = registry.select("Mine", &known).unwrap_err();
let second = registry.select("Mine", &known).unwrap_err();
assert_eq!(first, second);
assert_eq!(first.kind(), ViewDiagnosticKind::ScopeConflict);
assert!(first.message().contains("`alice`"));
assert!(first.message().contains("`zoe`"));
}
#[test]
fn owner_metadata_never_influences_selection() {
let mut registry = ViewRegistry::new();
registry
.insert(view(
"shared",
"Mine",
ViewScope::user_share(Some("someone-else".into()), vec!["another".into()]),
))
.unwrap();
let selected = registry.select("Mine", &projects(&[])).unwrap();
assert_eq!(selected.id.as_str(), "shared");
let mut other = ViewRegistry::new();
other
.insert(view(
"shared",
"Mine",
ViewScope::user_share(Some("alice".into()), vec!["bob".into()]),
))
.unwrap();
assert_eq!(
other.select("Mine", &projects(&[])).unwrap().id.as_str(),
"shared"
);
}
#[test]
fn select_reports_unknown_references_and_missing_projects() {
let mut registry = ViewRegistry::new();
registry
.insert(view("proj", "Platform", ViewScope::project("p-gone")))
.unwrap();
let refusal = registry.select("nope", &projects(&[])).unwrap_err();
assert_eq!(refusal.kind(), ViewDiagnosticKind::UnknownView);
let refusal = registry.select("proj", &projects(&[])).unwrap_err();
assert_eq!(refusal.kind(), ViewDiagnosticKind::ScopeConflict);
assert!(refusal.message().contains("p-gone"));
}
#[test]
fn unknown_metadata_is_reported_not_refused() {
let mut registry = ViewRegistry::new();
let mut record = view("platform", "Platform", ViewScope::repository());
record
.extensions
.insert("colour".into(), toml::Value::String("red".into()));
record
.scope
.extensions
.insert("visibility".into(), toml::Value::String("team".into()));
registry.insert(record).unwrap();
assert!(registry.check_structure().is_ok());
let diagnostics = registry.diagnostics(&projects(&[]));
let kinds: Vec<ViewDiagnosticKind> = diagnostics.iter().map(|d| d.kind()).collect();
assert_eq!(
kinds,
vec![
ViewDiagnosticKind::UnknownMetadata,
ViewDiagnosticKind::UnknownMetadata
]
);
assert_eq!(registry.iter().next().unwrap().extensions.len(), 1);
}
#[test]
fn rename_keeps_identity_scope_and_plan() {
let mut registry = ViewRegistry::new();
let mut record = view("platform", "Platform", ViewScope::project("p-platform"));
let mut plan = toml::Table::new();
plan.insert("sort".into(), toml::Value::String("due".into()));
record.plan = plan.clone();
registry.insert(record).unwrap();
let id = ViewId::new("platform").unwrap();
registry.rename(&id, "Platform active").unwrap();
let stored = registry.get(&id).unwrap();
assert_eq!(stored.id, id);
assert_eq!(stored.name, "Platform active");
assert_eq!(stored.scope, ViewScope::project("p-platform"));
assert_eq!(stored.plan, plan);
}
}