use std::path::Path;
use crate::config::{BoardConfiguration, Config};
use crate::search::Corpus;
use crate::views::divergence::{ActiveViewMarker, ViewDivergence, ViewSurface};
use crate::views::plan::{ViewApplyResult, ViewPlan};
use crate::views::registry::{
ProjectDirectory, SavedView, ViewDiagnostic, ViewDiagnosticKind, ViewId, ViewRefusal,
ViewRegistry, ViewResult, ViewScope, ViewWriteResult,
};
use crate::workflow::CategoryResolution;
pub type ViewMutation<T> = ViewWriteResult<ViewOutcome<T>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewMutationKind {
Create,
Update,
Duplicate,
Delete,
}
impl ViewMutationKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Create => "create",
Self::Update => "update",
Self::Duplicate => "duplicate",
Self::Delete => "delete",
}
}
}
impl std::fmt::Display for ViewMutationKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ViewAuditEvent {
kind: ViewMutationKind,
view_id: ViewId,
view_name: String,
}
impl ViewAuditEvent {
fn new(kind: ViewMutationKind, view_id: ViewId, view_name: String) -> Self {
Self {
kind,
view_id,
view_name,
}
}
#[must_use]
pub const fn kind(&self) -> ViewMutationKind {
self.kind
}
#[must_use]
pub fn view_id(&self) -> &ViewId {
&self.view_id
}
#[must_use]
pub fn view_name(&self) -> &str {
&self.view_name
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ViewOutcome<T> {
value: T,
audit: Option<ViewAuditEvent>,
}
impl<T> ViewOutcome<T> {
fn accepted(value: T, audit: ViewAuditEvent) -> Self {
Self {
value,
audit: Some(audit),
}
}
fn unchanged(value: T) -> Self {
Self { value, audit: None }
}
#[must_use]
pub fn value(&self) -> &T {
&self.value
}
#[must_use]
pub fn into_value(self) -> T {
self.value
}
#[must_use]
pub fn audit(&self) -> Option<&ViewAuditEvent> {
self.audit.as_ref()
}
#[must_use]
pub fn is_noop(&self) -> bool {
self.audit.is_none()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DeleteConfirmed(());
impl DeleteConfirmed {
#[must_use]
pub const fn confirmed() -> Self {
Self(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ViewDefinition {
name: String,
scope: ViewScope,
plan: ViewPlan,
}
impl ViewDefinition {
#[must_use]
pub fn new(name: impl Into<String>, scope: ViewScope, plan: ViewPlan) -> Self {
Self {
name: name.into(),
scope,
plan,
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn scope(&self) -> &ViewScope {
&self.scope
}
#[must_use]
pub fn plan(&self) -> &ViewPlan {
&self.plan
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ViewDetail {
view: SavedView,
plan: ViewPlan,
diagnostics: Vec<ViewDiagnostic>,
}
impl ViewDetail {
#[must_use]
pub fn view(&self) -> &SavedView {
&self.view
}
#[must_use]
pub fn plan(&self) -> &ViewPlan {
&self.plan
}
#[must_use]
pub fn diagnostics(&self) -> &[ViewDiagnostic] {
&self.diagnostics
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct DeletedView {
view: SavedView,
marker_cleared: bool,
}
impl DeletedView {
#[must_use]
pub fn view(&self) -> &SavedView {
&self.view
}
#[must_use]
pub const fn marker_cleared(&self) -> bool {
self.marker_cleared
}
}
pub struct ViewService<'a, P: ProjectDirectory + ?Sized> {
config: &'a mut Config,
projects: &'a P,
path: &'a Path,
active: Option<ActiveViewMarker>,
}
impl<'a, P: ProjectDirectory + ?Sized> ViewService<'a, P> {
#[must_use]
pub fn new(config: &'a mut Config, projects: &'a P, path: &'a Path) -> Self {
Self {
config,
projects,
path,
active: None,
}
}
#[must_use]
pub fn registry(&self) -> &ViewRegistry {
&self.config.views
}
#[must_use]
pub fn list(&self) -> Vec<SavedView> {
self.config.views.iter().cloned().collect()
}
pub fn show(&self, reference: &str) -> ViewResult<ViewDetail> {
let view = self.config.views.select(reference, self.projects)?;
let plan = ViewPlan::from_toml(&view.plan)?;
self.validate_plan(&plan)?;
let mut diagnostics: Vec<ViewDiagnostic> = self
.config
.views
.diagnostics(self.projects)
.into_iter()
.filter(|diagnostic| diagnostic.view_id() == Some(&view.id))
.collect();
diagnostics.extend(plan.diagnostics());
Ok(ViewDetail {
view: view.clone(),
plan,
diagnostics,
})
}
pub fn apply(
&self,
reference: &str,
board: &BoardConfiguration,
corpus: &Corpus,
resolution: &CategoryResolution,
) -> ViewResult<ViewApplyResult> {
let view = self.config.views.select(reference, self.projects)?;
let plan = ViewPlan::from_toml(&view.plan)?;
plan.apply_as(&view.id, board, self.config, corpus, resolution)
}
#[must_use]
pub fn active(&self) -> Option<&ActiveViewMarker> {
self.active.as_ref()
}
pub fn mark_active(&mut self, reference: &str, source: Option<ViewSurface>) -> ViewResult<()> {
let (id, plan) = {
let view = self.config.views.select(reference, self.projects)?;
let plan = ViewPlan::from_toml(&view.plan)?;
self.validate_plan(&plan)?;
(view.id.clone(), plan)
};
self.active = Some(ActiveViewMarker::new(id, plan, source));
Ok(())
}
pub fn set_active(&mut self, marker: ActiveViewMarker) {
self.active = Some(marker);
}
pub fn clear_active(&mut self) {
self.active = None;
}
#[must_use]
pub fn divergence(&self, live: &ViewPlan) -> ViewDivergence {
let Some(marker) = &self.active else {
return ViewDivergence::missing_marker(live);
};
match self.config.views.get(marker.view_id()) {
None => ViewDivergence::deleted(marker.view_id(), live),
Some(view) => match ViewPlan::from_toml(&view.plan) {
Ok(saved) => marker.divergence(&saved, live),
Err(refusal) => ViewDivergence::unreadable(
marker.view_id(),
live,
refusal.diagnostics().to_vec(),
),
},
}
}
pub fn create(&mut self, definition: ViewDefinition) -> ViewMutation<ViewId> {
self.validate_definition(&definition)?;
let mut candidate = self.config.views.clone();
let id = candidate.allocate_id();
let view = SavedView::new(id.clone(), definition.name(), definition.scope.clone())?
.with_plan(definition.plan().to_toml());
candidate.insert(view)?;
candidate.commit(self.config, self.projects, self.path)?;
let name = definition.name().trim().to_string();
Ok(ViewOutcome::accepted(
id.clone(),
ViewAuditEvent::new(ViewMutationKind::Create, id, name),
))
}
pub fn update(&mut self, reference: &str, definition: ViewDefinition) -> ViewMutation<ViewId> {
let id = self
.config
.views
.select(reference, self.projects)?
.id
.clone();
self.validate_definition(&definition)?;
let mut candidate = self.config.views.clone();
candidate.rename(&id, definition.name())?;
candidate.set_scope(&id, definition.scope.clone())?;
candidate.set_plan(&id, definition.plan().to_toml())?;
let name = definition.name().trim().to_string();
if candidate == self.config.views {
return Ok(ViewOutcome::unchanged(id));
}
candidate.commit(self.config, self.projects, self.path)?;
Ok(ViewOutcome::accepted(
id.clone(),
ViewAuditEvent::new(ViewMutationKind::Update, id, name),
))
}
pub fn duplicate(&mut self, reference: &str, name: Option<&str>) -> ViewMutation<ViewId> {
let source = self.config.views.select(reference, self.projects)?.clone();
self.validate_plan(&ViewPlan::from_toml(&source.plan)?)?;
let mut candidate = self.config.views.clone();
let id = candidate.allocate_id();
let label = name.map_or_else(|| source.name.clone(), str::to_string);
let copy = SavedView {
id: id.clone(),
name: label.trim().to_string(),
scope: source.scope.clone(),
plan: source.plan.clone(),
extensions: source.extensions.clone(),
};
candidate.insert(copy)?;
candidate.commit(self.config, self.projects, self.path)?;
let stored = candidate
.get(&id)
.map_or_else(|| label.trim().to_string(), |view| view.name.clone());
Ok(ViewOutcome::accepted(
id.clone(),
ViewAuditEvent::new(ViewMutationKind::Duplicate, id, stored),
))
}
pub fn delete(
&mut self,
reference: &str,
_confirmation: DeleteConfirmed,
) -> ViewMutation<DeletedView> {
let view = self.config.views.select(reference, self.projects)?.clone();
let mut candidate = self.config.views.clone();
let removed = candidate
.remove(&view.id)
.expect("the selected view is in the registry");
let marker_cleared = self
.active
.as_ref()
.is_some_and(|marker| marker.view_id() == &removed.id);
candidate.commit(self.config, self.projects, self.path)?;
if marker_cleared {
self.active = None;
}
let value = DeletedView {
view: removed.clone(),
marker_cleared,
};
Ok(ViewOutcome::accepted(
value,
ViewAuditEvent::new(
ViewMutationKind::Delete,
removed.id.clone(),
removed.name.clone(),
),
))
}
fn validate_definition(&self, definition: &ViewDefinition) -> ViewResult<()> {
if definition.name.trim().is_empty() {
return Err(ViewDiagnostic::new(
ViewDiagnosticKind::InvalidView,
"a saved view needs a name; the id is not a substitute for a label",
)
.with_field("name")
.with_remedy("give the view a display name")
.into());
}
let scope_conflicts = definition.scope.conflicts();
if !scope_conflicts.is_empty() {
return Err(ViewRefusal::from_diagnostics(scope_conflicts));
}
self.validate_plan(definition.plan())
}
fn validate_plan(&self, plan: &ViewPlan) -> ViewResult<()> {
let board = self.config.default_config().clone();
plan.validate(&board, self.config)
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::search::StructuredFilter;
use std::collections::BTreeSet;
use std::fs;
fn projects(ids: &[&str]) -> BTreeSet<String> {
ids.iter().map(|id| (*id).to_string()).collect()
}
fn service_fixture(raw: &str) -> (tempfile::TempDir, Config, BTreeSet<String>) {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("tix.toml");
fs::write(&path, raw).unwrap();
let config = Config::load(dir.path()).unwrap();
(dir, config, projects(&[]))
}
#[test]
fn create_then_show_round_trips_the_normalized_plan() {
let (dir, mut config, known) = service_fixture("issues_dir = \"docs/issues\"\n");
let path = dir.path().join("tix.toml");
let definition = ViewDefinition::new(
"Active",
ViewScope::repository(),
ViewPlan::from_parts(
StructuredFilter {
label: Some("urgent".into()),
..StructuredFilter::default()
},
"sort:due",
),
);
let mut service = ViewService::new(&mut config, &known, &path);
let outcome = service.create(definition.clone()).unwrap();
assert_eq!(outcome.audit().unwrap().kind(), ViewMutationKind::Create);
let id = outcome.into_value();
let detail = service.show(id.as_str()).unwrap();
assert_eq!(detail.plan().sort(), Some("due"));
assert_eq!(detail.plan().filter().label.as_deref(), Some("urgent"));
assert_eq!(detail.view().id, id);
}
}