1use std::collections::BTreeSet;
89use std::fmt;
90use std::hash::{BuildHasher, Hash, Hasher};
91use std::path::Path;
92use std::sync::atomic::{AtomicU64, Ordering};
93use std::time::{SystemTime, UNIX_EPOCH};
94
95use indexmap::IndexMap;
96use serde::{Deserialize, Serialize};
97
98use crate::config::Config;
99use crate::error::TixError;
100
101pub type ViewResult<T> = std::result::Result<T, ViewRefusal>;
108
109pub type ViewWriteResult<T> = std::result::Result<T, ViewWriteError>;
118
119#[derive(Debug)]
121pub enum ViewWriteError {
122 Refused(ViewRefusal),
124 Failed(TixError),
126}
127
128impl ViewWriteError {
129 #[must_use]
131 pub fn refusal(&self) -> Option<&ViewRefusal> {
132 match self {
133 Self::Refused(refusal) => Some(refusal),
134 Self::Failed(_) => None,
135 }
136 }
137
138 #[must_use]
140 pub fn failure(&self) -> Option<&TixError> {
141 match self {
142 Self::Refused(_) => None,
143 Self::Failed(error) => Some(error),
144 }
145 }
146}
147
148impl From<ViewRefusal> for ViewWriteError {
149 fn from(refusal: ViewRefusal) -> Self {
150 Self::Refused(refusal)
151 }
152}
153
154impl From<TixError> for ViewWriteError {
155 fn from(error: TixError) -> Self {
156 Self::Failed(error)
157 }
158}
159
160impl fmt::Display for ViewWriteError {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 match self {
163 Self::Refused(refusal) => write!(f, "{refusal}"),
164 Self::Failed(error) => write!(f, "{error}"),
165 }
166 }
167}
168
169impl std::error::Error for ViewWriteError {}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
179pub enum ViewDiagnosticKind {
180 InvalidView,
189 InvalidFilter,
191 InvalidSort,
193 InvalidGroup,
195 InvalidVisibility,
197 ScopeConflict,
199 UnknownView,
201 UnknownMetadata,
203}
204
205impl ViewDiagnosticKind {
206 #[must_use]
208 pub const fn as_str(self) -> &'static str {
209 match self {
210 Self::InvalidView => "invalid_view",
211 Self::InvalidFilter => "invalid_filter",
212 Self::InvalidSort => "invalid_sort",
213 Self::InvalidGroup => "invalid_group",
214 Self::InvalidVisibility => "invalid_visibility",
215 Self::ScopeConflict => "scope_conflict",
216 Self::UnknownView => "unknown_view",
217 Self::UnknownMetadata => "unknown_metadata",
218 }
219 }
220}
221
222impl fmt::Display for ViewDiagnosticKind {
223 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224 f.write_str(self.as_str())
225 }
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct ViewDiagnostic {
236 kind: ViewDiagnosticKind,
237 view_id: Option<ViewId>,
238 field: Option<String>,
239 message: String,
240 remedy: Option<String>,
241}
242
243impl ViewDiagnostic {
244 #[must_use]
246 pub fn for_view(
247 kind: ViewDiagnosticKind,
248 view_id: &ViewId,
249 message: impl Into<String>,
250 ) -> Self {
251 Self {
252 kind,
253 view_id: Some(view_id.clone()),
254 field: None,
255 message: message.into(),
256 remedy: None,
257 }
258 }
259
260 #[must_use]
262 pub fn new(kind: ViewDiagnosticKind, message: impl Into<String>) -> Self {
263 Self {
264 kind,
265 view_id: None,
266 field: None,
267 message: message.into(),
268 remedy: None,
269 }
270 }
271
272 #[must_use]
274 pub fn with_field(mut self, field: impl Into<String>) -> Self {
275 self.field = Some(field.into());
276 self
277 }
278
279 #[must_use]
281 pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
282 self.remedy = Some(remedy.into());
283 self
284 }
285
286 #[must_use]
288 pub const fn kind(&self) -> ViewDiagnosticKind {
289 self.kind
290 }
291
292 #[must_use]
294 pub fn view_id(&self) -> Option<&ViewId> {
295 self.view_id.as_ref()
296 }
297
298 #[must_use]
300 pub fn field(&self) -> Option<&str> {
301 self.field.as_deref()
302 }
303
304 #[must_use]
306 pub fn message(&self) -> &str {
307 &self.message
308 }
309
310 #[must_use]
312 pub fn remedy(&self) -> Option<&str> {
313 self.remedy.as_deref()
314 }
315}
316
317#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct ViewRefusal {
325 diagnostics: Vec<ViewDiagnostic>,
326}
327
328impl ViewRefusal {
329 #[must_use]
336 pub fn from_diagnostics(diagnostics: Vec<ViewDiagnostic>) -> Self {
337 assert!(
338 !diagnostics.is_empty(),
339 "a refusal carries at least one diagnostic"
340 );
341 Self { diagnostics }
342 }
343
344 #[must_use]
346 pub fn from_diagnostic(diagnostic: ViewDiagnostic) -> Self {
347 Self {
348 diagnostics: vec![diagnostic],
349 }
350 }
351
352 #[must_use]
354 pub fn diagnostics(&self) -> &[ViewDiagnostic] {
355 &self.diagnostics
356 }
357
358 #[must_use]
360 pub fn kind(&self) -> ViewDiagnosticKind {
361 self.diagnostics[0].kind()
362 }
363
364 #[must_use]
366 pub fn message(&self) -> &str {
367 self.diagnostics[0].message()
368 }
369
370 #[must_use]
372 pub fn render(&self) -> String {
373 crate::error::render_diagnostics(
374 "E-tix-views",
375 self.diagnostics
376 .iter()
377 .map(|diagnostic| (diagnostic.kind().as_str(), diagnostic.message())),
378 )
379 }
380}
381
382impl From<ViewDiagnostic> for ViewRefusal {
383 fn from(diagnostic: ViewDiagnostic) -> Self {
384 Self::from_diagnostic(diagnostic)
385 }
386}
387
388impl fmt::Display for ViewRefusal {
389 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390 f.write_str(&self.render())
391 }
392}
393
394impl std::error::Error for ViewRefusal {}
395
396#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
402#[serde(try_from = "String", into = "String")]
403pub struct ViewId(String);
404
405impl ViewId {
406 pub const MAX_LEN: usize = 128;
409
410 pub fn new(raw: &str) -> ViewResult<Self> {
417 if raw.trim().is_empty() {
418 return Err(ViewDiagnostic::new(
419 ViewDiagnosticKind::InvalidView,
420 "a view id must not be blank",
421 )
422 .with_field("id")
423 .with_remedy(
424 "choose an id such as `platform-active`, or omit it and let tix generate one",
425 )
426 .into());
427 }
428 if raw.trim() != raw {
429 return Err(ViewDiagnostic::new(
430 ViewDiagnosticKind::InvalidView,
431 format!("view id `{raw}` has leading or trailing whitespace"),
432 )
433 .with_field("id")
434 .into());
435 }
436 if raw.chars().count() > Self::MAX_LEN {
437 return Err(ViewDiagnostic::new(
438 ViewDiagnosticKind::InvalidView,
439 format!(
440 "view id is {} characters; the limit is {}",
441 raw.chars().count(),
442 Self::MAX_LEN
443 ),
444 )
445 .with_field("id")
446 .into());
447 }
448 if let Some(bad) = raw.chars().find(|c| is_structural(*c)) {
449 return Err(ViewDiagnostic::new(
450 ViewDiagnosticKind::InvalidView,
451 format!("view id `{raw}` contains `{bad}`"),
452 )
453 .with_field("id")
454 .with_remedy("use letters, digits, `-`, `_`, or `:`")
455 .into());
456 }
457 Ok(Self(raw.to_string()))
458 }
459
460 #[must_use]
468 pub fn generate() -> Self {
469 static COUNTER: AtomicU64 = AtomicU64::new(0);
470
471 let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
472 std::process::id().hash(&mut hasher);
473 COUNTER.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
474 SystemTime::now()
475 .duration_since(UNIX_EPOCH)
476 .map_or(0, |since| since.as_nanos())
477 .hash(&mut hasher);
478 let bits = hasher.finish();
479 Self(format!("v-{bits:016x}"))
482 }
483
484 #[must_use]
486 pub fn as_str(&self) -> &str {
487 &self.0
488 }
489}
490
491fn is_structural(c: char) -> bool {
494 c.is_whitespace()
495 || matches!(c, '.' | '"' | '\'' | '[' | ']' | '#' | '=' | '\\')
496 || c.is_control()
497}
498
499impl TryFrom<String> for ViewId {
500 type Error = ViewRefusal;
501
502 fn try_from(raw: String) -> ViewResult<Self> {
503 Self::new(&raw)
504 }
505}
506
507impl From<ViewId> for String {
508 fn from(id: ViewId) -> Self {
509 id.0
510 }
511}
512
513impl AsRef<str> for ViewId {
514 fn as_ref(&self) -> &str {
515 self.as_str()
516 }
517}
518
519impl fmt::Display for ViewId {
520 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
521 f.write_str(&self.0)
522 }
523}
524
525#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
527#[serde(rename_all = "snake_case")]
528pub enum ViewScopeKind {
529 #[default]
532 Repository,
533 Project,
535 UserShare,
537}
538
539impl ViewScopeKind {
540 #[must_use]
542 pub const fn as_str(self) -> &'static str {
543 match self {
544 Self::Repository => "repository",
545 Self::Project => "project",
546 Self::UserShare => "user_share",
547 }
548 }
549
550 #[must_use]
554 pub const fn precedence(self) -> u8 {
555 match self {
556 Self::Repository => 1,
557 Self::Project => 2,
558 Self::UserShare => 3,
559 }
560 }
561}
562
563impl fmt::Display for ViewScopeKind {
564 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
565 f.write_str(self.as_str())
566 }
567}
568
569#[derive(Debug, Clone, Copy, PartialEq, Eq)]
573pub enum ScopeResolution {
574 Resolved,
576 Missing,
578 Conflict,
580}
581
582impl ScopeResolution {
583 #[must_use]
585 pub const fn as_str(self) -> &'static str {
586 match self {
587 Self::Resolved => "resolved",
588 Self::Missing => "missing",
589 Self::Conflict => "conflict",
590 }
591 }
592}
593
594impl fmt::Display for ScopeResolution {
595 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
596 f.write_str(self.as_str())
597 }
598}
599
600pub trait ProjectDirectory {
609 fn contains(&self, project_id: &str) -> bool;
611}
612
613impl<T> ProjectDirectory for IndexMap<String, T> {
614 fn contains(&self, project_id: &str) -> bool {
615 self.contains_key(project_id)
616 }
617}
618
619impl ProjectDirectory for BTreeSet<String> {
620 fn contains(&self, project_id: &str) -> bool {
621 self.contains(project_id)
622 }
623}
624
625#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
633#[serde(default)]
634pub struct ViewScope {
635 pub kind: ViewScopeKind,
637 #[serde(skip_serializing_if = "Option::is_none")]
639 pub project_id: Option<String>,
640 #[serde(skip_serializing_if = "Option::is_none")]
642 pub owner: Option<String>,
643 #[serde(skip_serializing_if = "Vec::is_empty")]
645 pub shared_with: Vec<String>,
646 #[serde(flatten)]
648 pub extensions: toml::Table,
649}
650
651impl ViewScope {
652 #[must_use]
654 pub fn repository() -> Self {
655 Self::default()
656 }
657
658 #[must_use]
661 pub fn project(project_id: impl Into<String>) -> Self {
662 Self {
663 kind: ViewScopeKind::Project,
664 project_id: Some(project_id.into()),
665 ..Self::default()
666 }
667 }
668
669 #[must_use]
671 pub fn user_share(owner: Option<String>, shared_with: Vec<String>) -> Self {
672 Self {
673 kind: ViewScopeKind::UserShare,
674 project_id: None,
675 owner,
676 shared_with,
677 extensions: toml::Table::new(),
678 }
679 }
680
681 #[must_use]
688 pub fn conflicts(&self) -> Vec<ViewDiagnostic> {
689 let mut diagnostics = Vec::new();
690 let mut conflict = |field: &str, message: String, remedy: &str| {
691 diagnostics.push(
692 ViewDiagnostic::new(ViewDiagnosticKind::ScopeConflict, message)
693 .with_field(format!("scope.{field}"))
694 .with_remedy(remedy),
695 );
696 };
697
698 match self.kind {
699 ViewScopeKind::Repository => {
700 if self.project_id.is_some() {
701 conflict(
702 "project_id",
703 "a `repository` scope must not name a project".to_string(),
704 "use `kind = \"project\"` for a project scope, or drop `project_id`",
705 );
706 }
707 }
708 ViewScopeKind::Project => match self.project_id.as_deref() {
709 None => conflict(
710 "project_id",
711 "a `project` scope requires a project id".to_string(),
712 "set `project_id`, or use `kind = \"repository\"`",
713 ),
714 Some(project_id) if project_id.trim().is_empty() => conflict(
715 "project_id",
716 "a `project` scope has a blank project id".to_string(),
717 "set `project_id`, or use `kind = \"repository\"`",
718 ),
719 Some(project_id) if project_id.trim() != project_id => conflict(
720 "project_id",
721 format!("project id `{project_id}` has surrounding whitespace"),
722 "remove the whitespace",
723 ),
724 Some(_) => {}
725 },
726 ViewScopeKind::UserShare => {
727 if self.project_id.is_some() {
728 conflict(
729 "project_id",
730 "a `user_share` scope must not name a project".to_string(),
731 "drop `project_id`, or use `kind = \"project\"`",
732 );
733 }
734 if self.owner.is_none() && self.shared_with.is_empty() {
735 conflict(
736 "owner",
737 "a `user_share` scope must name an owner or at least one shared-with identity"
738 .to_string(),
739 "set `owner` or `shared_with`, or use `kind = \"repository\"`",
740 );
741 }
742 }
743 }
744
745 if self.kind != ViewScopeKind::UserShare {
746 if self.owner.is_some() {
747 conflict(
748 "owner",
749 format!("a `{}` scope must not carry an owner", self.kind.as_str()),
750 "owner/shared-with metadata belongs to `kind = \"user_share\"`",
751 );
752 }
753 if !self.shared_with.is_empty() {
754 conflict(
755 "shared_with",
756 format!(
757 "a `{}` scope must not carry shared-with identities",
758 self.kind.as_str()
759 ),
760 "owner/shared-with metadata belongs to `kind = \"user_share\"`",
761 );
762 }
763 }
764
765 if let Some(owner) = self.owner.as_deref()
766 && owner.trim().is_empty()
767 {
768 conflict(
769 "owner",
770 "`owner` is present but blank".to_string(),
771 "remove `owner`, or name the identity it belongs to",
772 );
773 }
774
775 let mut seen: Vec<&str> = Vec::new();
776 for identity in &self.shared_with {
777 if identity.trim().is_empty() {
778 conflict(
779 "shared_with",
780 "`shared_with` contains a blank identity".to_string(),
781 "remove the blank entry",
782 );
783 } else if seen.contains(&identity.as_str()) {
784 conflict(
785 "shared_with",
786 format!("`shared_with` names `{identity}` twice"),
787 "keep one entry per identity",
788 );
789 } else {
790 seen.push(identity.as_str());
791 }
792 }
793
794 diagnostics
795 }
796
797 #[must_use]
799 pub fn resolution<P: ProjectDirectory + ?Sized>(&self, projects: &P) -> ScopeResolution {
800 if !self.conflicts().is_empty() {
801 return ScopeResolution::Conflict;
802 }
803 match self.kind {
804 ViewScopeKind::Project => {
805 let known = self
806 .project_id
807 .as_deref()
808 .is_some_and(|project_id| projects.contains(project_id.trim()));
809 if known {
810 ScopeResolution::Resolved
811 } else {
812 ScopeResolution::Missing
813 }
814 }
815 ViewScopeKind::Repository | ViewScopeKind::UserShare => ScopeResolution::Resolved,
816 }
817 }
818}
819
820#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
826pub struct SavedView {
827 pub id: ViewId,
829 pub name: String,
831 #[serde(default)]
834 pub scope: ViewScope,
835 #[serde(default, skip_serializing_if = "toml::Table::is_empty")]
837 pub plan: toml::Table,
838 #[serde(flatten)]
840 pub extensions: toml::Table,
841}
842
843impl SavedView {
844 pub fn new(id: ViewId, name: &str, scope: ViewScope) -> ViewResult<Self> {
847 let view = Self {
848 id,
849 name: name.trim().to_string(),
850 scope,
851 plan: toml::Table::new(),
852 extensions: toml::Table::new(),
853 };
854 view.check_structure()?;
855 Ok(view)
856 }
857
858 #[must_use]
860 pub fn with_plan(mut self, plan: toml::Table) -> Self {
861 self.plan = plan;
862 self
863 }
864
865 pub fn check_structure(&self) -> ViewResult<()> {
868 let mut diagnostics = Vec::new();
869 if self.name.trim().is_empty() {
870 diagnostics.push(
871 ViewDiagnostic::for_view(
872 ViewDiagnosticKind::InvalidView,
873 &self.id,
874 format!("view `{}` has no name", self.id),
875 )
876 .with_field("name")
877 .with_remedy("set `name`; the id is not a substitute for a label"),
878 );
879 }
880 diagnostics.extend(
881 self.scope
882 .conflicts()
883 .into_iter()
884 .map(|diagnostic| attach_view(diagnostic, &self.id)),
885 );
886 if diagnostics.is_empty() {
887 Ok(())
888 } else {
889 Err(ViewRefusal::from_diagnostics(diagnostics))
890 }
891 }
892
893 fn unknown_metadata(&self) -> Vec<ViewDiagnostic> {
896 let mut diagnostics = Vec::new();
897 for key in self.extensions.keys() {
898 diagnostics.push(
899 ViewDiagnostic::for_view(
900 ViewDiagnosticKind::UnknownMetadata,
901 &self.id,
902 format!(
903 "view `{}` carries an unknown field `{key}`, preserved as written",
904 self.id
905 ),
906 )
907 .with_field(format!("extensions.{key}")),
908 );
909 }
910 for key in self.scope.extensions.keys() {
911 diagnostics.push(
912 ViewDiagnostic::for_view(
913 ViewDiagnosticKind::UnknownMetadata,
914 &self.id,
915 format!(
916 "view `{}` scope carries an unknown field `{key}`, preserved as written",
917 self.id
918 ),
919 )
920 .with_field(format!("scope.extensions.{key}")),
921 );
922 }
923 diagnostics
924 }
925}
926
927fn attach_view(diagnostic: ViewDiagnostic, view_id: &ViewId) -> ViewDiagnostic {
929 let mut out = ViewDiagnostic::for_view(diagnostic.kind(), view_id, diagnostic.message());
930 if let Some(field) = diagnostic.field() {
931 out = out.with_field(field.to_string());
932 }
933 if let Some(remedy) = diagnostic.remedy() {
934 out = out.with_remedy(remedy.to_string());
935 }
936 out
937}
938
939fn selected<'a, P: ProjectDirectory + ?Sized>(
945 view: &'a SavedView,
946 projects: &P,
947) -> ViewResult<&'a SavedView> {
948 match view.scope.resolution(projects) {
949 ScopeResolution::Resolved => Ok(view),
950 ScopeResolution::Missing => Err(missing_project(view).into()),
951 ScopeResolution::Conflict => {
952 let diagnostics: Vec<ViewDiagnostic> = view
953 .scope
954 .conflicts()
955 .into_iter()
956 .map(|diagnostic| attach_view(diagnostic, &view.id))
957 .collect();
958 Err(ViewRefusal::from_diagnostics(diagnostics))
959 }
960 }
961}
962
963fn missing_project(view: &SavedView) -> ViewDiagnostic {
967 ViewDiagnostic::for_view(
968 ViewDiagnosticKind::ScopeConflict,
969 &view.id,
970 format!(
971 "view `{}` is scoped to project `{}`, which is not in the project registry",
972 view.id,
973 view.scope.project_id.as_deref().unwrap_or("")
974 ),
975 )
976 .with_field("scope.project_id")
977 .with_remedy("create the project, or re-scope the view to the repository")
978}
979
980#[derive(Debug, Clone, PartialEq)]
987pub struct ViewRegistry {
988 views: IndexMap<String, SavedView>,
989}
990
991impl Default for ViewRegistry {
992 fn default() -> Self {
993 Self::new()
994 }
995}
996
997impl ViewRegistry {
998 #[must_use]
1000 pub fn new() -> Self {
1001 Self {
1002 views: IndexMap::new(),
1003 }
1004 }
1005
1006 #[must_use]
1008 pub fn len(&self) -> usize {
1009 self.views.len()
1010 }
1011
1012 #[must_use]
1014 pub fn is_empty(&self) -> bool {
1015 self.views.is_empty()
1016 }
1017
1018 pub fn iter(&self) -> impl Iterator<Item = &SavedView> {
1020 self.views.values()
1021 }
1022
1023 #[must_use]
1025 pub fn get(&self, id: &ViewId) -> Option<&SavedView> {
1026 self.views.get(id.as_str())
1027 }
1028
1029 #[must_use]
1031 pub fn contains(&self, id: &ViewId) -> bool {
1032 self.views.contains_key(id.as_str())
1033 }
1034
1035 #[must_use]
1042 pub fn allocate_id(&self) -> ViewId {
1043 loop {
1044 let candidate = ViewId::generate();
1045 if !self.contains(&candidate) {
1046 return candidate;
1047 }
1048 }
1049 }
1050
1051 pub fn insert(&mut self, view: SavedView) -> ViewResult<()> {
1055 if let Some(existing) = self.views.get(view.id.as_str()) {
1056 return Err(ViewDiagnostic::for_view(
1057 ViewDiagnosticKind::InvalidView,
1058 &view.id,
1059 format!(
1060 "view id `{}` is already taken by `{}`; ids are never reused",
1061 view.id, existing.name
1062 ),
1063 )
1064 .with_field("id")
1065 .with_remedy("choose a different id, or edit the view that owns it")
1066 .into());
1067 }
1068 view.check_structure()?;
1069 self.views.insert(view.id.as_str().to_string(), view);
1070 Ok(())
1071 }
1072
1073 pub fn rename(&mut self, id: &ViewId, name: &str) -> ViewResult<()> {
1076 let trimmed = name.trim();
1077 if trimmed.is_empty() {
1078 return Err(ViewDiagnostic::for_view(
1079 ViewDiagnosticKind::InvalidView,
1080 id,
1081 format!("view `{id}` cannot be renamed to a blank name"),
1082 )
1083 .with_field("name")
1084 .into());
1085 }
1086 let view = self.views.get_mut(id.as_str()).ok_or_else(|| {
1087 ViewDiagnostic::new(
1088 ViewDiagnosticKind::UnknownView,
1089 format!("no saved view with id `{id}`"),
1090 )
1091 .with_field("id")
1092 })?;
1093 view.name = trimmed.to_string();
1094 Ok(())
1095 }
1096
1097 pub fn set_scope(&mut self, id: &ViewId, scope: ViewScope) -> ViewResult<()> {
1099 let diagnostics: Vec<ViewDiagnostic> = scope
1100 .conflicts()
1101 .into_iter()
1102 .map(|diagnostic| attach_view(diagnostic, id))
1103 .collect();
1104 if !diagnostics.is_empty() {
1105 return Err(ViewRefusal::from_diagnostics(diagnostics));
1106 }
1107 let view = self.views.get_mut(id.as_str()).ok_or_else(|| {
1108 ViewDiagnostic::new(
1109 ViewDiagnosticKind::UnknownView,
1110 format!("no saved view with id `{id}`"),
1111 )
1112 .with_field("id")
1113 })?;
1114 view.scope = scope;
1115 Ok(())
1116 }
1117
1118 pub fn set_plan(&mut self, id: &ViewId, plan: toml::Table) -> ViewResult<()> {
1120 let view = self.views.get_mut(id.as_str()).ok_or_else(|| {
1121 ViewDiagnostic::new(
1122 ViewDiagnosticKind::UnknownView,
1123 format!("no saved view with id `{id}`"),
1124 )
1125 .with_field("id")
1126 })?;
1127 view.plan = plan;
1128 Ok(())
1129 }
1130
1131 pub fn remove(&mut self, id: &ViewId) -> Option<SavedView> {
1141 self.views.shift_remove(id.as_str())
1142 }
1143
1144 pub fn select<P: ProjectDirectory + ?Sized>(
1157 &self,
1158 reference: &str,
1159 projects: &P,
1160 ) -> ViewResult<&SavedView> {
1161 let reference = reference.trim();
1162 if let Some(view) = self.views.get(reference) {
1163 return selected(view, projects);
1164 }
1165
1166 let mut candidates: Vec<&SavedView> = self
1167 .views
1168 .values()
1169 .filter(|view| view.name == reference)
1170 .collect();
1171 if candidates.is_empty() {
1172 return Err(ViewDiagnostic::new(
1173 ViewDiagnosticKind::UnknownView,
1174 format!("no saved view matches `{reference}`"),
1175 )
1176 .with_field("id")
1177 .with_remedy("list the saved views and select one by id")
1178 .into());
1179 }
1180
1181 candidates.sort_by(|left, right| left.id.cmp(&right.id));
1182 let best = candidates
1183 .iter()
1184 .map(|view| view.scope.kind.precedence())
1185 .max()
1186 .unwrap_or(ViewScopeKind::Repository.precedence());
1187 let winners: Vec<&SavedView> = candidates
1188 .into_iter()
1189 .filter(|view| view.scope.kind.precedence() == best)
1190 .collect();
1191
1192 if winners.len() > 1 {
1193 let ids = winners
1194 .iter()
1195 .map(|view| format!("`{}`", view.id))
1196 .collect::<Vec<_>>()
1197 .join(", ");
1198 let kinds = winners
1199 .iter()
1200 .map(|view| view.scope.kind.as_str())
1201 .collect::<Vec<_>>()
1202 .join(", ");
1203 return Err(ViewDiagnostic::new(
1204 ViewDiagnosticKind::ScopeConflict,
1205 format!(
1206 "view name `{reference}` is ambiguous: {ids} are all {} scope",
1207 kinds
1208 ),
1209 )
1210 .with_field("name")
1211 .with_remedy("select the view by its stable id")
1212 .into());
1213 }
1214
1215 selected(winners[0], projects)
1217 }
1218
1219 pub fn check_structure(&self) -> ViewResult<()> {
1222 let mut diagnostics = Vec::new();
1223 for view in self.views.values() {
1224 if let Err(refusal) = view.check_structure() {
1225 diagnostics.extend(refusal.diagnostics().iter().cloned());
1226 }
1227 }
1228 if diagnostics.is_empty() {
1229 Ok(())
1230 } else {
1231 Err(ViewRefusal::from_diagnostics(diagnostics))
1232 }
1233 }
1234
1235 pub fn check<P: ProjectDirectory + ?Sized>(&self, projects: &P) -> ViewResult<()> {
1242 let mut diagnostics = Vec::new();
1243 if let Err(refusal) = self.check_structure() {
1244 diagnostics.extend(refusal.diagnostics().iter().cloned());
1245 }
1246 for view in self.views.values() {
1247 if let ScopeResolution::Missing = view.scope.resolution(projects) {
1248 diagnostics.push(missing_project(view));
1249 }
1250 }
1251 if diagnostics.is_empty() {
1252 Ok(())
1253 } else {
1254 Err(ViewRefusal::from_diagnostics(diagnostics))
1255 }
1256 }
1257
1258 #[must_use]
1266 pub fn diagnostics<P: ProjectDirectory + ?Sized>(&self, projects: &P) -> Vec<ViewDiagnostic> {
1267 let mut diagnostics = Vec::new();
1268 for view in self.views.values() {
1269 let conflicts = view.scope.conflicts();
1270 if conflicts.is_empty() {
1271 if let ScopeResolution::Missing = view.scope.resolution(projects) {
1272 diagnostics.push(missing_project(view));
1273 }
1274 } else {
1275 diagnostics.extend(
1278 conflicts
1279 .into_iter()
1280 .map(|diagnostic| attach_view(diagnostic, &view.id)),
1281 );
1282 }
1283 diagnostics.extend(view.unknown_metadata());
1284 }
1285 diagnostics
1286 }
1287
1288 pub fn commit<P: ProjectDirectory + ?Sized>(
1306 &self,
1307 config: &mut Config,
1308 projects: &P,
1309 path: &Path,
1310 ) -> ViewWriteResult<()> {
1311 self.check(projects).map_err(ViewWriteError::Refused)?;
1312
1313 let mut candidate = config.clone();
1314 candidate.views = self.clone();
1315 candidate.validate().map_err(ViewWriteError::Failed)?;
1316 candidate.save(path).map_err(ViewWriteError::Failed)?;
1317
1318 *config = candidate;
1319 Ok(())
1320 }
1321
1322 pub(crate) fn restore_declaration_order(&mut self, contents: &str) {
1325 crate::config::restore_section_order(&mut self.views, contents, "views");
1326 }
1327}
1328
1329impl Serialize for ViewRegistry {
1330 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1331 where
1332 S: serde::Serializer,
1333 {
1334 self.views.serialize(serializer)
1335 }
1336}
1337
1338impl<'de> Deserialize<'de> for ViewRegistry {
1339 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1340 where
1341 D: serde::Deserializer<'de>,
1342 {
1343 let stored = IndexMap::<String, SavedView>::deserialize(deserializer)?;
1344 let mut registry = Self::new();
1345 for (key, view) in stored {
1346 if key != view.id.as_str() {
1347 return Err(serde::de::Error::custom(format!(
1348 "the `[views.{key}]` record declares id `{}`: the table key is the stable \
1349 identity and the two must agree",
1350 view.id.as_str()
1351 )));
1352 }
1353 registry.views.insert(key, view);
1354 }
1355 Ok(registry)
1356 }
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361 #[allow(clippy::wildcard_imports)]
1362 use super::*;
1363
1364 fn projects(ids: &[&str]) -> BTreeSet<String> {
1365 ids.iter().map(|id| (*id).to_string()).collect()
1366 }
1367
1368 fn view(id: &str, name: &str, scope: ViewScope) -> SavedView {
1369 SavedView::new(ViewId::new(id).unwrap(), name, scope).unwrap()
1370 }
1371
1372 #[test]
1373 fn blank_and_ill_formed_ids_are_refused() {
1374 assert!(ViewId::new("").is_err());
1375 assert!(ViewId::new(" ").is_err());
1376 assert!(ViewId::new(" padded").is_err());
1377 assert!(ViewId::new("a.b").is_err());
1378 assert!(ViewId::new("a[b]").is_err());
1379 assert!(ViewId::new("a b").is_err());
1380 assert!(ViewId::new(&"x".repeat(ViewId::MAX_LEN + 1)).is_err());
1381 assert_eq!(
1382 ViewId::new("platform-active").unwrap().as_str(),
1383 "platform-active"
1384 );
1385 }
1386
1387 #[test]
1388 fn generated_ids_are_unique_and_well_formed() {
1389 let first = ViewId::generate();
1390 let second = ViewId::generate();
1391 assert_ne!(first, second);
1392 assert!(first.as_str().starts_with("v-"));
1393 assert_eq!(first.as_str().len(), 18);
1394 assert!(ViewId::new(first.as_str()).is_ok());
1395 }
1396
1397 #[test]
1398 fn allocate_id_skips_stored_ids() {
1399 let mut registry = ViewRegistry::new();
1400 registry
1401 .insert(view("taken", "Taken", ViewScope::repository()))
1402 .unwrap();
1403 for _ in 0..32 {
1404 assert!(!registry.contains(®istry.allocate_id()));
1405 }
1406 }
1407
1408 #[test]
1409 fn duplicate_id_is_a_refusal_not_an_overwrite() {
1410 let mut registry = ViewRegistry::new();
1411 registry
1412 .insert(view("platform", "Platform", ViewScope::repository()))
1413 .unwrap();
1414 let refusal = registry
1415 .insert(view("platform", "Something else", ViewScope::repository()))
1416 .unwrap_err();
1417 assert_eq!(refusal.kind(), ViewDiagnosticKind::InvalidView);
1418 assert_eq!(
1419 registry
1420 .get(&ViewId::new("platform").unwrap())
1421 .unwrap()
1422 .name,
1423 "Platform",
1424 "an existing definition is never overwritten by a duplicate id"
1425 );
1426 }
1427
1428 #[test]
1429 fn blank_names_are_refused_on_insert_and_rename() {
1430 let mut registry = ViewRegistry::new();
1431 let refusal = SavedView::new(
1432 ViewId::new("platform").unwrap(),
1433 " ",
1434 ViewScope::repository(),
1435 )
1436 .unwrap_err();
1437 assert_eq!(refusal.kind(), ViewDiagnosticKind::InvalidView);
1438
1439 registry
1440 .insert(view("platform", "Platform", ViewScope::repository()))
1441 .unwrap();
1442 let id = ViewId::new("platform").unwrap();
1443 assert!(registry.rename(&id, " ").is_err());
1444 registry.rename(&id, " Platform active ").unwrap();
1445 assert_eq!(registry.get(&id).unwrap().name, "Platform active");
1446 }
1447
1448 #[test]
1449 fn scope_kinds_rank_by_specificity() {
1450 assert!(ViewScopeKind::UserShare.precedence() > ViewScopeKind::Project.precedence());
1451 assert!(ViewScopeKind::Project.precedence() > ViewScopeKind::Repository.precedence());
1452 }
1453
1454 #[test]
1455 fn repository_scope_must_not_name_a_project() {
1456 let scope = ViewScope {
1457 kind: ViewScopeKind::Repository,
1458 project_id: Some("p-platform".into()),
1459 ..ViewScope::default()
1460 };
1461 let conflicts = scope.conflicts();
1462 assert_eq!(conflicts.len(), 1);
1463 assert_eq!(conflicts[0].field(), Some("scope.project_id"));
1464 assert_eq!(
1465 scope.resolution(&projects(&["p-platform"])),
1466 ScopeResolution::Conflict
1467 );
1468 }
1469
1470 #[test]
1471 fn project_scope_requires_a_project_and_resolves_through_the_directory() {
1472 let empty = ViewScope::project(" ");
1473 assert_eq!(empty.conflicts().len(), 1);
1474
1475 let scope = ViewScope::project("p-platform");
1476 assert_eq!(
1477 scope.resolution(&projects(&["p-platform"])),
1478 ScopeResolution::Resolved
1479 );
1480 assert_eq!(scope.resolution(&projects(&[])), ScopeResolution::Missing);
1481 }
1482
1483 #[test]
1484 fn user_share_scope_needs_owner_or_shared_with_and_is_metadata_only() {
1485 assert_eq!(ViewScope::user_share(None, vec![]).conflicts().len(), 1);
1486 let scope = ViewScope::user_share(Some("alice".into()), vec!["bob".into()]);
1487 assert_eq!(
1488 scope.conflicts(),
1489 [] as [crate::views::registry::ViewDiagnostic; 0]
1490 );
1491 assert_eq!(scope.resolution(&projects(&[])), ScopeResolution::Resolved);
1492 }
1493
1494 #[test]
1495 fn user_share_scope_must_not_name_a_project() {
1496 let scope = ViewScope {
1500 kind: ViewScopeKind::UserShare,
1501 project_id: Some("p-platform".into()),
1502 owner: Some("alice".into()),
1503 ..ViewScope::default()
1504 };
1505 let conflicts = scope.conflicts();
1506 assert_eq!(conflicts.len(), 1);
1507 assert_eq!(conflicts[0].field(), Some("scope.project_id"));
1508 assert_eq!(
1509 scope.resolution(&projects(&["p-platform"])),
1510 ScopeResolution::Conflict
1511 );
1512 }
1513
1514 #[test]
1515 fn share_metadata_on_a_non_share_scope_is_a_conflict() {
1516 let scope = ViewScope {
1517 kind: ViewScopeKind::Repository,
1518 owner: Some("alice".into()),
1519 shared_with: vec!["bob".into()],
1520 ..ViewScope::default()
1521 };
1522 let conflicts = scope.conflicts();
1523 let fields: Vec<Option<&str>> = conflicts.iter().map(|d| d.field()).collect();
1524 assert_eq!(fields, vec![Some("scope.owner"), Some("scope.shared_with")]);
1525 }
1526
1527 #[test]
1528 fn repeated_and_blank_shared_with_identities_are_refused() {
1529 let scope = ViewScope::user_share(Some("alice".into()), vec!["bob".into(), "bob".into()]);
1530 assert_eq!(scope.conflicts().len(), 1);
1531 let scope = ViewScope::user_share(Some("alice".into()), vec![" ".into()]);
1532 assert_eq!(scope.conflicts().len(), 1);
1533 }
1534
1535 #[test]
1536 fn select_prefers_the_id_and_then_the_most_specific_scope() {
1537 let mut registry = ViewRegistry::new();
1538 registry
1539 .insert(view("repo", "Active", ViewScope::repository()))
1540 .unwrap();
1541 registry
1542 .insert(view("proj", "Active", ViewScope::project("p-platform")))
1543 .unwrap();
1544 let known = projects(&["p-platform"]);
1545 assert_eq!(registry.select("proj", &known).unwrap().id.as_str(), "proj");
1546 assert_eq!(
1547 registry.select("Active", &known).unwrap().id.as_str(),
1548 "proj"
1549 );
1550 }
1551
1552 #[test]
1553 fn user_share_tie_is_a_deterministic_conflict() {
1554 let mut registry = ViewRegistry::new();
1555 registry
1556 .insert(view(
1557 "zoe",
1558 "Mine",
1559 ViewScope::user_share(Some("zoe".into()), vec![]),
1560 ))
1561 .unwrap();
1562 registry
1563 .insert(view(
1564 "alice",
1565 "Mine",
1566 ViewScope::user_share(Some("alice".into()), vec![]),
1567 ))
1568 .unwrap();
1569 let known = projects(&[]);
1570 let first = registry.select("Mine", &known).unwrap_err();
1571 let second = registry.select("Mine", &known).unwrap_err();
1572 assert_eq!(first, second);
1573 assert_eq!(first.kind(), ViewDiagnosticKind::ScopeConflict);
1574 assert!(first.message().contains("`alice`"));
1575 assert!(first.message().contains("`zoe`"));
1576 }
1577
1578 #[test]
1579 fn owner_metadata_never_influences_selection() {
1580 let mut registry = ViewRegistry::new();
1581 registry
1582 .insert(view(
1583 "shared",
1584 "Mine",
1585 ViewScope::user_share(Some("someone-else".into()), vec!["another".into()]),
1586 ))
1587 .unwrap();
1588 let selected = registry.select("Mine", &projects(&[])).unwrap();
1589 assert_eq!(selected.id.as_str(), "shared");
1590
1591 let mut other = ViewRegistry::new();
1594 other
1595 .insert(view(
1596 "shared",
1597 "Mine",
1598 ViewScope::user_share(Some("alice".into()), vec!["bob".into()]),
1599 ))
1600 .unwrap();
1601 assert_eq!(
1602 other.select("Mine", &projects(&[])).unwrap().id.as_str(),
1603 "shared"
1604 );
1605 }
1606
1607 #[test]
1608 fn select_reports_unknown_references_and_missing_projects() {
1609 let mut registry = ViewRegistry::new();
1610 registry
1611 .insert(view("proj", "Platform", ViewScope::project("p-gone")))
1612 .unwrap();
1613 let refusal = registry.select("nope", &projects(&[])).unwrap_err();
1614 assert_eq!(refusal.kind(), ViewDiagnosticKind::UnknownView);
1615
1616 let refusal = registry.select("proj", &projects(&[])).unwrap_err();
1617 assert_eq!(refusal.kind(), ViewDiagnosticKind::ScopeConflict);
1618 assert!(refusal.message().contains("p-gone"));
1619 }
1620
1621 #[test]
1622 fn unknown_metadata_is_reported_not_refused() {
1623 let mut registry = ViewRegistry::new();
1624 let mut record = view("platform", "Platform", ViewScope::repository());
1625 record
1626 .extensions
1627 .insert("colour".into(), toml::Value::String("red".into()));
1628 record
1629 .scope
1630 .extensions
1631 .insert("visibility".into(), toml::Value::String("team".into()));
1632 registry.insert(record).unwrap();
1633
1634 assert!(registry.check_structure().is_ok());
1635 let diagnostics = registry.diagnostics(&projects(&[]));
1636 let kinds: Vec<ViewDiagnosticKind> = diagnostics.iter().map(|d| d.kind()).collect();
1637 assert_eq!(
1638 kinds,
1639 vec![
1640 ViewDiagnosticKind::UnknownMetadata,
1641 ViewDiagnosticKind::UnknownMetadata
1642 ]
1643 );
1644 assert_eq!(registry.iter().next().unwrap().extensions.len(), 1);
1645 }
1646
1647 #[test]
1648 fn rename_keeps_identity_scope_and_plan() {
1649 let mut registry = ViewRegistry::new();
1650 let mut record = view("platform", "Platform", ViewScope::project("p-platform"));
1651 let mut plan = toml::Table::new();
1652 plan.insert("sort".into(), toml::Value::String("due".into()));
1653 record.plan = plan.clone();
1654 registry.insert(record).unwrap();
1655
1656 let id = ViewId::new("platform").unwrap();
1657 registry.rename(&id, "Platform active").unwrap();
1658 let stored = registry.get(&id).unwrap();
1659 assert_eq!(stored.id, id);
1660 assert_eq!(stored.name, "Platform active");
1661 assert_eq!(stored.scope, ViewScope::project("p-platform"));
1662 assert_eq!(stored.plan, plan);
1663 }
1664}