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)]
168 pub fn new(
169 scope: AnnotationScope,
170 kind: AnnotationKind,
171 content: String,
172 tags: Vec<String>,
173 attribution: String,
174 created_at: i64,
175 source_hash: Option<ContentHash>,
176 created_at_state: Option<StateId>,
177 ) -> Self {
178 Self {
179 annotation_id: uuid::Uuid::now_v7().to_string(),
180 scope,
181 status: AnnotationStatus::Active,
182 revisions: vec![AnnotationRevision {
183 revision_id: uuid::Uuid::now_v7().to_string(),
184 kind,
185 content,
186 tags,
187 attribution,
188 created_at,
189 source_hash,
190 created_at_state,
191 }],
192 supersedes_annotation_id: None,
193 supersedes_rewrite_pct: None,
194 visibility: VisibilityTier::default(),
195 resolved_from_discussion: None,
196 anchor_status: AnnotationAnchorStatus::default(),
197 divergent_revision_ids: Vec::new(),
198 }
199 }
200
201 pub fn current_revision(&self) -> Option<&AnnotationRevision> {
202 self.revisions.last()
203 }
204
205 pub fn current_revision_mut(&mut self) -> Option<&mut AnnotationRevision> {
206 self.revisions.last_mut()
207 }
208
209 #[allow(clippy::too_many_arguments)]
210 pub fn revise(
211 &mut self,
212 kind: AnnotationKind,
213 content: String,
214 tags: Vec<String>,
215 attribution: String,
216 created_at: i64,
217 source_hash: Option<ContentHash>,
218 created_at_state: Option<StateId>,
219 ) -> &AnnotationRevision {
220 self.revisions.push(AnnotationRevision {
221 revision_id: uuid::Uuid::now_v7().to_string(),
222 kind,
223 content,
224 tags,
225 attribution,
226 created_at,
227 source_hash,
228 created_at_state,
229 });
230 self.divergent_revision_ids.clear();
231 &self.revisions[self.revisions.len() - 1]
232 }
233
234 pub fn mark_superseded(&mut self) {
235 self.status = AnnotationStatus::Superseded;
236 }
237
238 pub fn validate(&self) -> Result<(), ContextError> {
239 self.scope.validate()?;
240 if self.annotation_id.is_empty() {
241 return Err(ContextError::InvalidEncoding(
242 "annotation_id must not be empty".to_string(),
243 ));
244 }
245 if self.revisions.is_empty() {
246 return Err(ContextError::MissingRevisions(self.annotation_id.clone()));
247 }
248 for revision in &self.revisions {
249 revision.validate()?;
250 }
251 if self.divergent_revision_ids.len() == 1
252 || self.divergent_revision_ids.len() > 64
253 || self
254 .divergent_revision_ids
255 .iter()
256 .collect::<std::collections::BTreeSet<_>>()
257 .len()
258 != self.divergent_revision_ids.len()
259 || self.divergent_revision_ids.iter().any(|id| {
260 !self
261 .revisions
262 .iter()
263 .any(|revision| revision.revision_id == *id)
264 })
265 {
266 return Err(ContextError::InvalidEncoding(format!(
267 "invalid divergent revision frontier for {}",
268 self.annotation_id
269 )));
270 }
271 Ok(())
272 }
273
274 pub fn current_revision_ids(&self) -> Vec<&str> {
275 if self.divergent_revision_ids.is_empty() {
276 self.current_revision()
277 .map(|revision| vec![revision.revision_id.as_str()])
278 .unwrap_or_default()
279 } else {
280 self.divergent_revision_ids
281 .iter()
282 .map(String::as_str)
283 .collect()
284 }
285 }
286}
287
288impl AnnotationRevision {
289 pub fn validate(&self) -> Result<(), ContextError> {
290 if self.revision_id.is_empty() {
291 return Err(ContextError::InvalidEncoding(
292 "revision_id must not be empty".to_string(),
293 ));
294 }
295 Ok(())
296 }
297}
298
299impl AnnotationKind {
300 pub fn as_str(&self) -> &'static str {
301 match self {
302 Self::Constraint => "constraint",
303 Self::Invariant => "invariant",
304 Self::Rationale => "rationale",
305 }
306 }
307}
308
309impl std::fmt::Display for AnnotationKind {
310 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311 write!(f, "{}", self.as_str())
312 }
313}
314
315impl std::str::FromStr for AnnotationKind {
316 type Err = ContextError;
317
318 fn from_str(value: &str) -> Result<Self, Self::Err> {
319 match value {
320 "constraint" => Ok(Self::Constraint),
321 "invariant" => Ok(Self::Invariant),
322 "rationale" => Ok(Self::Rationale),
323 _ => Err(ContextError::InvalidEncoding(format!(
324 "invalid annotation kind '{value}'"
325 ))),
326 }
327 }
328}
329
330impl ContextTarget {
331 pub fn file(path: impl Into<String>) -> Result<Self, ContextError> {
344 let path = path.into();
345 if path.trim().is_empty() {
346 return Err(ContextError::EmptyTargetPath);
347 }
348 let p = Path::new(&path);
349 if p.is_absolute() {
350 return Err(ContextError::AbsoluteTargetPath(path));
351 }
352 let mut saw_normal = false;
357 for component in p.components() {
358 match component {
359 Component::Normal(_) => saw_normal = true,
360 Component::CurDir => {}
361 Component::ParentDir => {
362 return Err(ContextError::InvalidTargetPath(path));
363 }
364 Component::RootDir | Component::Prefix(_) => {
365 return Err(ContextError::AbsoluteTargetPath(path));
370 }
371 }
372 }
373 if !saw_normal {
374 return Err(ContextError::InvalidTargetPath(path));
375 }
376 Ok(Self::File { path })
377 }
378
379 pub fn state(state_id: StateId) -> Self {
380 Self::State { state_id }
381 }
382
383 pub fn validate_scope(&self, scope: &AnnotationScope) -> Result<(), ContextError> {
384 match self {
385 Self::File { .. } => scope.validate(),
386 Self::State { .. } => {
387 if matches!(scope, AnnotationScope::File) {
388 Ok(())
389 } else {
390 Err(ContextError::StateTargetMustUseFileScope)
391 }
392 }
393 }
394 }
395
396 pub fn storage_path(&self) -> PathBuf {
397 match self {
398 Self::File { path } => Path::new(FILE_TARGET_ROOT).join(path),
399 Self::State { state_id } => {
400 Path::new(STATE_TARGET_ROOT).join(state_id.to_string_full())
401 }
402 }
403 }
404
405 pub fn from_storage_path(path: &Path) -> Option<Self> {
406 let mut components = path.components();
407 match components.next()? {
408 Component::Normal(part) if part == FILE_TARGET_ROOT => {
409 let rest = components.as_path();
410 if rest.as_os_str().is_empty() {
411 None
412 } else {
413 Some(Self::File {
414 path: rest.to_string_lossy().to_string(),
415 })
416 }
417 }
418 Component::Normal(part) if part == STATE_TARGET_ROOT => {
419 let rest = components.as_path();
420 let mut state_components = rest.components();
421 let Component::Normal(id) = state_components.next()? else {
422 return None;
423 };
424 if !state_components.as_path().as_os_str().is_empty() {
425 return None;
426 }
427 StateId::parse(&id.to_string_lossy())
428 .ok()
429 .map(|state_id| Self::State { state_id })
430 }
431 _ => None,
432 }
433 }
434
435 pub fn path(&self) -> Option<&str> {
436 match self {
437 Self::File { path } => Some(path),
438 Self::State { .. } => None,
439 }
440 }
441
442 pub fn state_id(&self) -> Option<StateId> {
443 match self {
444 Self::State { state_id } => Some(*state_id),
445 Self::File { .. } => None,
446 }
447 }
448}
449
450impl AnnotationScope {
451 pub fn validate(&self) -> Result<(), ContextError> {
452 match self {
453 Self::File => Ok(()),
454 Self::Symbol {
455 name,
456 resolved_lines,
457 } => {
458 if name.is_empty() {
459 return Err(ContextError::EmptySymbol);
460 }
461 if let Some((start, end)) = resolved_lines
462 && start > end
463 {
464 return Err(ContextError::InvalidLineRange(*start, *end));
465 }
466 Ok(())
467 }
468 Self::Lines(start, end) => {
469 if start > end {
470 Err(ContextError::InvalidLineRange(*start, *end))
471 } else {
472 Ok(())
473 }
474 }
475 }
476 }
477
478 pub fn matches(&self, other: &Self) -> bool {
479 match (self, other) {
480 (Self::File, Self::File) => true,
481 (Self::Symbol { name: a, .. }, Self::Symbol { name: b, .. }) => a == b,
482 (Self::Lines(a1, a2), Self::Lines(b1, b2)) => a1 == b1 && a2 == b2,
483 _ => false,
484 }
485 }
486
487 pub fn symbol_name(&self) -> Option<&str> {
488 match self {
489 Self::Symbol { name, .. } => Some(name),
490 _ => None,
491 }
492 }
493
494 pub fn line_range(&self) -> Option<(u32, u32)> {
495 match self {
496 Self::Lines(start, end) => Some((*start, *end)),
497 Self::Symbol {
498 resolved_lines: Some((start, end)),
499 ..
500 } => Some((*start, *end)),
501 _ => None,
502 }
503 }
504}
505
506impl std::fmt::Display for AnnotationScope {
507 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
508 match self {
509 Self::File => write!(f, "file"),
510 Self::Symbol { name, .. } => write!(f, "symbol:{name}"),
511 Self::Lines(start, end) => write!(f, "lines:{start}-{end}"),
512 }
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 #[test]
523 fn context_target_accepts_relative_paths() {
524 assert!(ContextTarget::file("src/auth.rs").is_ok());
526 assert!(ContextTarget::file("a/b/c.txt").is_ok());
527 assert!(ContextTarget::file(".gitignore").is_ok());
528 assert!(ContextTarget::file("a").is_ok());
529 assert!(ContextTarget::file("./a").is_ok());
532 }
533
534 #[test]
535 fn context_target_rejects_empty_path() {
536 assert!(matches!(
537 ContextTarget::file(""),
538 Err(ContextError::EmptyTargetPath)
539 ));
540 assert!(matches!(
541 ContextTarget::file(" "),
542 Err(ContextError::EmptyTargetPath)
543 ));
544 }
545
546 #[test]
547 fn context_target_rejects_absolute_path_unix() {
548 let err = ContextTarget::file("/Users/me/repo/src/auth.rs").unwrap_err();
549 assert!(
550 matches!(err, ContextError::AbsoluteTargetPath(ref p) if p == "/Users/me/repo/src/auth.rs"),
551 "got {err:?}"
552 );
553 assert!(matches!(
555 ContextTarget::file("/"),
556 Err(ContextError::AbsoluteTargetPath(_))
557 ));
558 }
559
560 #[test]
561 fn context_target_rejects_parent_escape() {
562 assert!(matches!(
565 ContextTarget::file("../etc/passwd"),
566 Err(ContextError::InvalidTargetPath(_))
567 ));
568 assert!(matches!(
569 ContextTarget::file("src/../../escape"),
570 Err(ContextError::InvalidTargetPath(_))
571 ));
572 }
573
574 #[test]
575 fn context_target_rejects_all_dot_components() {
576 assert!(matches!(
580 ContextTarget::file("."),
581 Err(ContextError::InvalidTargetPath(_))
582 ));
583 assert!(matches!(
584 ContextTarget::file("./."),
585 Err(ContextError::InvalidTargetPath(_))
586 ));
587 }
588
589 #[test]
590 fn roundtrips_revision_with_missing_source_hash_and_present_state() {
591 let created_at_state = StateId::from_bytes([3; 32]);
592 let blob = ContextBlob::new(vec![Annotation::new(
593 AnnotationScope::File,
594 AnnotationKind::Rationale,
595 "Entry point".to_string(),
596 vec!["critical".to_string()],
597 "test@example.com".to_string(),
598 1700000000,
599 None,
600 Some(created_at_state),
601 )]);
602
603 let encoded = blob.encode().unwrap();
604 let decoded = ContextBlob::decode(&encoded).unwrap();
605 let revision = decoded.annotations[0].current_revision().unwrap();
606 assert_eq!(revision.source_hash, None);
607 assert_eq!(revision.created_at_state, Some(created_at_state));
608 }
609
610 #[test]
611 fn roundtrip_serialization() {
612 let blob = ContextBlob::new(vec![Annotation::new(
613 AnnotationScope::File,
614 AnnotationKind::Invariant,
615 "Entry point".to_string(),
616 vec!["constraint".to_string()],
617 "test@example.com".to_string(),
618 1700000000,
619 None,
620 None,
621 )]);
622
623 let bytes = blob.encode().unwrap();
624 let decoded = ContextBlob::decode(&bytes).unwrap();
625 assert_eq!(blob, decoded);
626 }
627
628 #[test]
629 fn legacy_annotation_without_anchor_status_decodes_as_resolved() {
630 #[derive(Serialize)]
631 struct LegacyAnnotation {
632 annotation_id: String,
633 scope: AnnotationScope,
634 status: AnnotationStatus,
635 revisions: Vec<AnnotationRevision>,
636 supersedes_annotation_id: Option<String>,
637 supersedes_rewrite_pct: Option<u32>,
638 visibility: VisibilityTier,
639 resolved_from_discussion: Option<String>,
640 }
641
642 #[derive(Serialize)]
643 struct LegacyContextBlob {
644 format_version: u8,
645 annotations: Vec<LegacyAnnotation>,
646 }
647
648 let revision = AnnotationRevision {
649 revision_id: "legacy-revision".to_string(),
650 kind: AnnotationKind::Invariant,
651 content: "legacy context".to_string(),
652 tags: vec![],
653 attribution: "test@example.com".to_string(),
654 created_at: 1_700_000_000,
655 source_hash: None,
656 created_at_state: None,
657 };
658 let bytes = rmp_serde::to_vec(&LegacyContextBlob {
659 format_version: ContextBlob::FORMAT_VERSION,
660 annotations: vec![LegacyAnnotation {
661 annotation_id: "legacy-annotation".to_string(),
662 scope: AnnotationScope::File,
663 status: AnnotationStatus::Active,
664 revisions: vec![revision],
665 supersedes_annotation_id: None,
666 supersedes_rewrite_pct: None,
667 visibility: VisibilityTier::default(),
668 resolved_from_discussion: None,
669 }],
670 })
671 .unwrap();
672
673 let decoded = ContextBlob::decode(&bytes).unwrap();
674 assert_eq!(
675 decoded.annotations[0].anchor_status,
676 AnnotationAnchorStatus::Resolved
677 );
678 }
679
680 #[test]
681 fn validate_good_blob() {
682 let blob = ContextBlob::new(vec![]);
683 blob.validate().unwrap();
684 }
685
686 #[test]
687 fn validate_bad_version() {
688 let blob = ContextBlob {
689 format_version: 99,
690 annotations: vec![],
691 };
692 assert!(matches!(
693 blob.validate(),
694 Err(ContextError::UnsupportedVersion(99))
695 ));
696 }
697
698 #[test]
699 fn validate_bad_line_range() {
700 let blob = ContextBlob::new(vec![Annotation::new(
701 AnnotationScope::Lines(20, 10),
702 AnnotationKind::Rationale,
703 "bad".to_string(),
704 vec![],
705 "test".to_string(),
706 0,
707 None,
708 None,
709 )]);
710 assert!(matches!(
711 blob.validate(),
712 Err(ContextError::InvalidLineRange(20, 10))
713 ));
714 }
715
716 #[test]
717 fn validate_empty_symbol() {
718 let blob = ContextBlob::new(vec![Annotation::new(
719 AnnotationScope::Symbol {
720 name: String::new(),
721 resolved_lines: None,
722 },
723 AnnotationKind::Rationale,
724 "bad".to_string(),
725 vec![],
726 "test".to_string(),
727 0,
728 None,
729 None,
730 )]);
731 assert!(matches!(blob.validate(), Err(ContextError::EmptySymbol)));
732 }
733
734 #[test]
735 fn scope_matching() {
736 assert!(AnnotationScope::File.matches(&AnnotationScope::File));
737 assert!(
738 AnnotationScope::Symbol {
739 name: "foo".into(),
740 resolved_lines: None
741 }
742 .matches(&AnnotationScope::Symbol {
743 name: "foo".into(),
744 resolved_lines: Some((1, 5))
745 })
746 );
747 assert!(
748 !AnnotationScope::Symbol {
749 name: "foo".into(),
750 resolved_lines: None
751 }
752 .matches(&AnnotationScope::Symbol {
753 name: "bar".into(),
754 resolved_lines: None
755 })
756 );
757 assert!(AnnotationScope::Lines(1, 10).matches(&AnnotationScope::Lines(1, 10)));
758 }
759
760 #[test]
761 fn state_targets_only_allow_file_scope() {
762 let target = ContextTarget::state(StateId::from_bytes([1; 32]));
763 assert!(target.validate_scope(&AnnotationScope::File).is_ok());
764 assert!(matches!(
765 target.validate_scope(&AnnotationScope::Lines(1, 2)),
766 Err(ContextError::StateTargetMustUseFileScope)
767 ));
768 }
769
770 #[test]
771 fn context_target_storage_roundtrip() {
772 let file = ContextTarget::file("src/main.rs").unwrap();
773 assert_eq!(
774 ContextTarget::from_storage_path(&file.storage_path()),
775 Some(file.clone())
776 );
777
778 let state = ContextTarget::state(StateId::from_bytes([2; 32]));
779 assert_eq!(
780 ContextTarget::from_storage_path(&state.storage_path()),
781 Some(state)
782 );
783 }
784
785 #[test]
786 fn context_target_storage_rejects_legacy_direct_paths() {
787 assert_eq!(
788 ContextTarget::from_storage_path(Path::new("src/main.rs")),
789 None
790 );
791 }
792}