1use std::path::{Component, Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::object::{
9 hash::{ContentHash, StateId},
10 visibility_tier::VisibilityTier,
11};
12
13const FILE_TARGET_ROOT: &str = "__files";
14const STATE_TARGET_ROOT: &str = "__states";
15
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ContextBlob {
19 pub format_version: u8,
20 pub annotations: Vec<Annotation>,
21}
22
23#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
25pub struct Annotation {
26 pub annotation_id: String,
27 pub scope: AnnotationScope,
28 pub status: AnnotationStatus,
29 pub revisions: Vec<AnnotationRevision>,
30 #[serde(default)]
31 pub supersedes_annotation_id: Option<String>,
32 #[serde(default)]
33 pub supersedes_rewrite_pct: Option<u32>,
34 #[serde(default)]
39 pub visibility: VisibilityTier,
40 #[serde(default)]
44 pub resolved_from_discussion: Option<String>,
45 #[serde(default)]
48 pub anchor_status: AnnotationAnchorStatus,
49 #[serde(default)]
52 pub divergent_revision_ids: Vec<String>,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
57pub struct AnnotationRevision {
58 pub revision_id: String,
59 pub kind: AnnotationKind,
60 pub content: String,
61 pub tags: Vec<String>,
62 pub attribution: String,
63 pub created_at: i64,
64 #[serde(default)]
68 pub source_hash: Option<ContentHash>,
69 #[serde(default)]
72 pub created_at_state: Option<StateId>,
73}
74
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
76pub enum AnnotationStatus {
77 Active,
78 Superseded,
79 Deleted,
82}
83
84#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
86pub enum AnnotationAnchorStatus {
87 #[default]
89 Resolved,
90 Ambiguous { candidate_paths: Vec<String> },
92 Orphaned,
94}
95
96#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum AnnotationKind {
104 Constraint,
106 Invariant,
108 Rationale,
110}
111
112#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
114pub enum ContextTarget {
115 File { path: String },
116 State { state_id: StateId },
117}
118
119#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
121pub enum AnnotationScope {
122 File,
123 Symbol {
124 name: String,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
128 resolved_lines: Option<(u32, u32)>,
129 },
130 Lines(u32, u32),
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
134pub enum ContextError {
135 #[error("unsupported context format version {0}")]
136 UnsupportedVersion(u8),
137 #[error("line range start {0} exceeds end {1}")]
138 InvalidLineRange(u32, u32),
139 #[error("symbol name must not be empty")]
140 EmptySymbol,
141 #[error("file target path must not be empty")]
142 EmptyTargetPath,
143 #[error("context target path must be relative, got: {0}")]
144 AbsoluteTargetPath(String),
145 #[error("invalid context target path: {0}")]
146 InvalidTargetPath(String),
147 #[error("state-level guidance must use file scope only")]
148 StateTargetMustUseFileScope,
149 #[error("annotation {0} has no revisions")]
150 MissingRevisions(String),
151 #[error("invalid context encoding: {0}")]
152 InvalidEncoding(String),
153}
154
155versioned_msgpack_blob! {
158 blob: ContextBlob,
159 item: Annotation,
160 field: annotations,
161 error: ContextError,
162 codec_err: InvalidEncoding,
163 version: 2,
164}
165
166impl Annotation {
167 #[allow(clippy::too_many_arguments)]
170 pub fn new(
171 scope: AnnotationScope,
172 kind: AnnotationKind,
173 content: String,
174 tags: Vec<String>,
175 attribution: String,
176 created_at: i64,
177 source_hash: Option<ContentHash>,
178 created_at_state: Option<StateId>,
179 visibility: VisibilityTier,
180 ) -> Self {
181 Self {
182 annotation_id: uuid::Uuid::now_v7().to_string(),
183 scope,
184 status: AnnotationStatus::Active,
185 revisions: vec![AnnotationRevision {
186 revision_id: uuid::Uuid::now_v7().to_string(),
187 kind,
188 content,
189 tags,
190 attribution,
191 created_at,
192 source_hash,
193 created_at_state,
194 }],
195 supersedes_annotation_id: None,
196 supersedes_rewrite_pct: None,
197 visibility,
198 resolved_from_discussion: None,
199 anchor_status: AnnotationAnchorStatus::default(),
200 divergent_revision_ids: Vec::new(),
201 }
202 }
203
204 pub fn current_revision(&self) -> Option<&AnnotationRevision> {
205 self.revisions.last()
206 }
207
208 pub fn current_revision_mut(&mut self) -> Option<&mut AnnotationRevision> {
209 self.revisions.last_mut()
210 }
211
212 #[allow(clippy::too_many_arguments)]
213 pub fn revise(
214 &mut self,
215 kind: AnnotationKind,
216 content: String,
217 tags: Vec<String>,
218 attribution: String,
219 created_at: i64,
220 source_hash: Option<ContentHash>,
221 created_at_state: Option<StateId>,
222 ) -> &AnnotationRevision {
223 self.revisions.push(AnnotationRevision {
224 revision_id: uuid::Uuid::now_v7().to_string(),
225 kind,
226 content,
227 tags,
228 attribution,
229 created_at,
230 source_hash,
231 created_at_state,
232 });
233 self.divergent_revision_ids.clear();
234 &self.revisions[self.revisions.len() - 1]
235 }
236
237 pub fn mark_superseded(&mut self) {
238 self.status = AnnotationStatus::Superseded;
239 }
240
241 pub fn validate(&self) -> Result<(), ContextError> {
242 self.scope.validate()?;
243 if self.annotation_id.is_empty() {
244 return Err(ContextError::InvalidEncoding(
245 "annotation_id must not be empty".to_string(),
246 ));
247 }
248 if self.revisions.is_empty() {
249 return Err(ContextError::MissingRevisions(self.annotation_id.clone()));
250 }
251 for revision in &self.revisions {
252 revision.validate()?;
253 }
254 if self.divergent_revision_ids.len() == 1
255 || self.divergent_revision_ids.len() > 64
256 || self
257 .divergent_revision_ids
258 .iter()
259 .collect::<std::collections::BTreeSet<_>>()
260 .len()
261 != self.divergent_revision_ids.len()
262 || self.divergent_revision_ids.iter().any(|id| {
263 !self
264 .revisions
265 .iter()
266 .any(|revision| revision.revision_id == *id)
267 })
268 {
269 return Err(ContextError::InvalidEncoding(format!(
270 "invalid divergent revision frontier for {}",
271 self.annotation_id
272 )));
273 }
274 Ok(())
275 }
276
277 pub fn current_revision_ids(&self) -> Vec<&str> {
278 if self.divergent_revision_ids.is_empty() {
279 self.current_revision()
280 .map(|revision| vec![revision.revision_id.as_str()])
281 .unwrap_or_default()
282 } else {
283 self.divergent_revision_ids
284 .iter()
285 .map(String::as_str)
286 .collect()
287 }
288 }
289}
290
291impl AnnotationRevision {
292 pub fn validate(&self) -> Result<(), ContextError> {
293 if self.revision_id.is_empty() {
294 return Err(ContextError::InvalidEncoding(
295 "revision_id must not be empty".to_string(),
296 ));
297 }
298 Ok(())
299 }
300}
301
302impl AnnotationKind {
303 pub fn as_str(&self) -> &'static str {
304 match self {
305 Self::Constraint => "constraint",
306 Self::Invariant => "invariant",
307 Self::Rationale => "rationale",
308 }
309 }
310}
311
312impl std::fmt::Display for AnnotationKind {
313 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314 write!(f, "{}", self.as_str())
315 }
316}
317
318impl std::str::FromStr for AnnotationKind {
319 type Err = ContextError;
320
321 fn from_str(value: &str) -> Result<Self, Self::Err> {
322 match value {
323 "constraint" => Ok(Self::Constraint),
324 "invariant" => Ok(Self::Invariant),
325 "rationale" => Ok(Self::Rationale),
326 _ => Err(ContextError::InvalidEncoding(format!(
327 "invalid annotation kind '{value}'"
328 ))),
329 }
330 }
331}
332
333impl ContextTarget {
334 pub fn file(path: impl Into<String>) -> Result<Self, ContextError> {
347 let path = path.into();
348 if path.trim().is_empty() {
349 return Err(ContextError::EmptyTargetPath);
350 }
351 let p = Path::new(&path);
352 if p.is_absolute() {
353 return Err(ContextError::AbsoluteTargetPath(path));
354 }
355 let mut saw_normal = false;
360 for component in p.components() {
361 match component {
362 Component::Normal(_) => saw_normal = true,
363 Component::CurDir => {}
364 Component::ParentDir => {
365 return Err(ContextError::InvalidTargetPath(path));
366 }
367 Component::RootDir | Component::Prefix(_) => {
368 return Err(ContextError::AbsoluteTargetPath(path));
373 }
374 }
375 }
376 if !saw_normal {
377 return Err(ContextError::InvalidTargetPath(path));
378 }
379 Ok(Self::File { path })
380 }
381
382 pub fn state(state_id: StateId) -> Self {
383 Self::State { state_id }
384 }
385
386 pub fn validate_scope(&self, scope: &AnnotationScope) -> Result<(), ContextError> {
387 match self {
388 Self::File { .. } => scope.validate(),
389 Self::State { .. } => {
390 if matches!(scope, AnnotationScope::File) {
391 Ok(())
392 } else {
393 Err(ContextError::StateTargetMustUseFileScope)
394 }
395 }
396 }
397 }
398
399 pub fn storage_path(&self) -> PathBuf {
400 match self {
401 Self::File { path } => Path::new(FILE_TARGET_ROOT).join(path),
402 Self::State { state_id } => {
403 Path::new(STATE_TARGET_ROOT).join(state_id.to_string_full())
404 }
405 }
406 }
407
408 pub fn from_storage_path(path: &Path) -> Option<Self> {
409 let mut components = path.components();
410 match components.next()? {
411 Component::Normal(part) if part == FILE_TARGET_ROOT => {
412 let rest = components.as_path();
413 if rest.as_os_str().is_empty() {
414 None
415 } else {
416 Some(Self::File {
417 path: rest.to_string_lossy().to_string(),
418 })
419 }
420 }
421 Component::Normal(part) if part == STATE_TARGET_ROOT => {
422 let rest = components.as_path();
423 let mut state_components = rest.components();
424 let Component::Normal(id) = state_components.next()? else {
425 return None;
426 };
427 if !state_components.as_path().as_os_str().is_empty() {
428 return None;
429 }
430 StateId::parse(&id.to_string_lossy())
431 .ok()
432 .map(|state_id| Self::State { state_id })
433 }
434 _ => None,
435 }
436 }
437
438 pub fn path(&self) -> Option<&str> {
439 match self {
440 Self::File { path } => Some(path),
441 Self::State { .. } => None,
442 }
443 }
444
445 pub fn state_id(&self) -> Option<StateId> {
446 match self {
447 Self::State { state_id } => Some(*state_id),
448 Self::File { .. } => None,
449 }
450 }
451}
452
453impl AnnotationScope {
454 pub fn validate(&self) -> Result<(), ContextError> {
455 match self {
456 Self::File => Ok(()),
457 Self::Symbol {
458 name,
459 resolved_lines,
460 } => {
461 if name.is_empty() {
462 return Err(ContextError::EmptySymbol);
463 }
464 if let Some((start, end)) = resolved_lines
465 && start > end
466 {
467 return Err(ContextError::InvalidLineRange(*start, *end));
468 }
469 Ok(())
470 }
471 Self::Lines(start, end) => {
472 if start > end {
473 Err(ContextError::InvalidLineRange(*start, *end))
474 } else {
475 Ok(())
476 }
477 }
478 }
479 }
480
481 pub fn matches(&self, other: &Self) -> bool {
482 match (self, other) {
483 (Self::File, Self::File) => true,
484 (Self::Symbol { name: a, .. }, Self::Symbol { name: b, .. }) => a == b,
485 (Self::Lines(a1, a2), Self::Lines(b1, b2)) => a1 == b1 && a2 == b2,
486 _ => false,
487 }
488 }
489
490 pub fn symbol_name(&self) -> Option<&str> {
491 match self {
492 Self::Symbol { name, .. } => Some(name),
493 _ => None,
494 }
495 }
496
497 pub fn line_range(&self) -> Option<(u32, u32)> {
498 match self {
499 Self::Lines(start, end) => Some((*start, *end)),
500 Self::Symbol {
501 resolved_lines: Some((start, end)),
502 ..
503 } => Some((*start, *end)),
504 _ => None,
505 }
506 }
507}
508
509impl std::fmt::Display for AnnotationScope {
510 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
511 match self {
512 Self::File => write!(f, "file"),
513 Self::Symbol { name, .. } => write!(f, "symbol:{name}"),
514 Self::Lines(start, end) => write!(f, "lines:{start}-{end}"),
515 }
516 }
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 #[test]
524 fn explicit_visibility_survives_revision_and_blob_round_trip() {
525 for visibility in [
526 VisibilityTier::Public,
527 VisibilityTier::Internal,
528 VisibilityTier::TeamScoped {
529 team_id: "engineering".into(),
530 },
531 VisibilityTier::Restricted {
532 scope_label: "evaluation".into(),
533 },
534 VisibilityTier::Private {
535 scope_label: "evaluation".into(),
536 },
537 ] {
538 let mut annotation = Annotation::new(
539 AnnotationScope::File,
540 AnnotationKind::Invariant,
541 "decision".into(),
542 vec![],
543 "test@example.com".into(),
544 1,
545 None,
546 None,
547 visibility.clone(),
548 );
549 annotation.revise(
550 AnnotationKind::Invariant,
551 "revised decision".into(),
552 vec![],
553 "test@example.com".into(),
554 2,
555 None,
556 None,
557 );
558 let decoded =
559 ContextBlob::decode(&ContextBlob::new(vec![annotation]).encode().expect("encode"))
560 .expect("decode");
561 assert_eq!(decoded.annotations[0].visibility, visibility);
562 assert_eq!(decoded.annotations[0].revisions.len(), 2);
563 }
564 }
565
566 #[test]
569 fn context_target_accepts_relative_paths() {
570 assert!(ContextTarget::file("src/auth.rs").is_ok());
572 assert!(ContextTarget::file("a/b/c.txt").is_ok());
573 assert!(ContextTarget::file(".gitignore").is_ok());
574 assert!(ContextTarget::file("a").is_ok());
575 assert!(ContextTarget::file("./a").is_ok());
578 }
579
580 #[test]
581 fn context_target_rejects_empty_path() {
582 assert!(matches!(
583 ContextTarget::file(""),
584 Err(ContextError::EmptyTargetPath)
585 ));
586 assert!(matches!(
587 ContextTarget::file(" "),
588 Err(ContextError::EmptyTargetPath)
589 ));
590 }
591
592 #[test]
593 fn context_target_rejects_absolute_path_unix() {
594 let err = ContextTarget::file("/Users/me/repo/src/auth.rs").unwrap_err();
595 assert!(
596 matches!(err, ContextError::AbsoluteTargetPath(ref p) if p == "/Users/me/repo/src/auth.rs"),
597 "got {err:?}"
598 );
599 assert!(matches!(
601 ContextTarget::file("/"),
602 Err(ContextError::AbsoluteTargetPath(_))
603 ));
604 }
605
606 #[test]
607 fn context_target_rejects_parent_escape() {
608 assert!(matches!(
611 ContextTarget::file("../etc/passwd"),
612 Err(ContextError::InvalidTargetPath(_))
613 ));
614 assert!(matches!(
615 ContextTarget::file("src/../../escape"),
616 Err(ContextError::InvalidTargetPath(_))
617 ));
618 }
619
620 #[test]
621 fn context_target_rejects_all_dot_components() {
622 assert!(matches!(
626 ContextTarget::file("."),
627 Err(ContextError::InvalidTargetPath(_))
628 ));
629 assert!(matches!(
630 ContextTarget::file("./."),
631 Err(ContextError::InvalidTargetPath(_))
632 ));
633 }
634
635 #[test]
636 fn roundtrips_revision_with_missing_source_hash_and_present_state() {
637 let created_at_state = StateId::from_bytes([3; 32]);
638 let blob = ContextBlob::new(vec![Annotation::new(
639 AnnotationScope::File,
640 AnnotationKind::Rationale,
641 "Entry point".to_string(),
642 vec!["critical".to_string()],
643 "test@example.com".to_string(),
644 1700000000,
645 None,
646 Some(created_at_state),
647 crate::object::VisibilityTier::Public,
648 )]);
649
650 let encoded = blob.encode().unwrap();
651 let decoded = ContextBlob::decode(&encoded).unwrap();
652 let revision = decoded.annotations[0].current_revision().unwrap();
653 assert_eq!(revision.source_hash, None);
654 assert_eq!(revision.created_at_state, Some(created_at_state));
655 }
656
657 #[test]
658 fn roundtrip_serialization() {
659 let blob = ContextBlob::new(vec![Annotation::new(
660 AnnotationScope::File,
661 AnnotationKind::Invariant,
662 "Entry point".to_string(),
663 vec!["constraint".to_string()],
664 "test@example.com".to_string(),
665 1700000000,
666 None,
667 None,
668 crate::object::VisibilityTier::Public,
669 )]);
670
671 let bytes = blob.encode().unwrap();
672 let decoded = ContextBlob::decode(&bytes).unwrap();
673 assert_eq!(blob, decoded);
674 }
675
676 #[test]
677 fn legacy_annotation_without_anchor_status_decodes_as_resolved() {
678 #[derive(Serialize)]
679 struct LegacyAnnotation {
680 annotation_id: String,
681 scope: AnnotationScope,
682 status: AnnotationStatus,
683 revisions: Vec<AnnotationRevision>,
684 supersedes_annotation_id: Option<String>,
685 supersedes_rewrite_pct: Option<u32>,
686 visibility: VisibilityTier,
687 resolved_from_discussion: Option<String>,
688 }
689
690 #[derive(Serialize)]
691 struct LegacyContextBlob {
692 format_version: u8,
693 annotations: Vec<LegacyAnnotation>,
694 }
695
696 let revision = AnnotationRevision {
697 revision_id: "legacy-revision".to_string(),
698 kind: AnnotationKind::Invariant,
699 content: "legacy context".to_string(),
700 tags: vec![],
701 attribution: "test@example.com".to_string(),
702 created_at: 1_700_000_000,
703 source_hash: None,
704 created_at_state: None,
705 };
706 let bytes = rmp_serde::to_vec(&LegacyContextBlob {
707 format_version: ContextBlob::FORMAT_VERSION,
708 annotations: vec![LegacyAnnotation {
709 annotation_id: "legacy-annotation".to_string(),
710 scope: AnnotationScope::File,
711 status: AnnotationStatus::Active,
712 revisions: vec![revision],
713 supersedes_annotation_id: None,
714 supersedes_rewrite_pct: None,
715 visibility: VisibilityTier::default(),
716 resolved_from_discussion: None,
717 }],
718 })
719 .unwrap();
720
721 let decoded = ContextBlob::decode(&bytes).unwrap();
722 assert_eq!(
723 decoded.annotations[0].anchor_status,
724 AnnotationAnchorStatus::Resolved
725 );
726 }
727
728 #[test]
729 fn validate_good_blob() {
730 let blob = ContextBlob::new(vec![]);
731 blob.validate().unwrap();
732 }
733
734 #[test]
735 fn validate_bad_version() {
736 let blob = ContextBlob {
737 format_version: 99,
738 annotations: vec![],
739 };
740 assert!(matches!(
741 blob.validate(),
742 Err(ContextError::UnsupportedVersion(99))
743 ));
744 }
745
746 #[test]
747 fn validate_bad_line_range() {
748 let blob = ContextBlob::new(vec![Annotation::new(
749 AnnotationScope::Lines(20, 10),
750 AnnotationKind::Rationale,
751 "bad".to_string(),
752 vec![],
753 "test".to_string(),
754 0,
755 None,
756 None,
757 crate::object::VisibilityTier::Public,
758 )]);
759 assert!(matches!(
760 blob.validate(),
761 Err(ContextError::InvalidLineRange(20, 10))
762 ));
763 }
764
765 #[test]
766 fn validate_empty_symbol() {
767 let blob = ContextBlob::new(vec![Annotation::new(
768 AnnotationScope::Symbol {
769 name: String::new(),
770 resolved_lines: None,
771 },
772 AnnotationKind::Rationale,
773 "bad".to_string(),
774 vec![],
775 "test".to_string(),
776 0,
777 None,
778 None,
779 crate::object::VisibilityTier::Public,
780 )]);
781 assert!(matches!(blob.validate(), Err(ContextError::EmptySymbol)));
782 }
783
784 #[test]
785 fn scope_matching() {
786 assert!(AnnotationScope::File.matches(&AnnotationScope::File));
787 assert!(
788 AnnotationScope::Symbol {
789 name: "foo".into(),
790 resolved_lines: None
791 }
792 .matches(&AnnotationScope::Symbol {
793 name: "foo".into(),
794 resolved_lines: Some((1, 5))
795 })
796 );
797 assert!(
798 !AnnotationScope::Symbol {
799 name: "foo".into(),
800 resolved_lines: None
801 }
802 .matches(&AnnotationScope::Symbol {
803 name: "bar".into(),
804 resolved_lines: None
805 })
806 );
807 assert!(AnnotationScope::Lines(1, 10).matches(&AnnotationScope::Lines(1, 10)));
808 }
809
810 #[test]
811 fn state_targets_only_allow_file_scope() {
812 let target = ContextTarget::state(StateId::from_bytes([1; 32]));
813 assert!(target.validate_scope(&AnnotationScope::File).is_ok());
814 assert!(matches!(
815 target.validate_scope(&AnnotationScope::Lines(1, 2)),
816 Err(ContextError::StateTargetMustUseFileScope)
817 ));
818 }
819
820 #[test]
821 fn context_target_storage_roundtrip() {
822 let file = ContextTarget::file("src/main.rs").unwrap();
823 assert_eq!(
824 ContextTarget::from_storage_path(&file.storage_path()),
825 Some(file.clone())
826 );
827
828 let state = ContextTarget::state(StateId::from_bytes([2; 32]));
829 assert_eq!(
830 ContextTarget::from_storage_path(&state.storage_path()),
831 Some(state)
832 );
833 }
834
835 #[test]
836 fn context_target_storage_rejects_legacy_direct_paths() {
837 assert_eq!(
838 ContextTarget::from_storage_path(Path::new("src/main.rs")),
839 None
840 );
841 }
842}