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}
50
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53pub struct AnnotationRevision {
54 pub revision_id: String,
55 pub kind: AnnotationKind,
56 pub content: String,
57 pub tags: Vec<String>,
58 pub attribution: String,
59 pub created_at: i64,
60 #[serde(default)]
64 pub source_hash: Option<ContentHash>,
65 #[serde(default)]
68 pub created_at_state: Option<StateId>,
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
72pub enum AnnotationStatus {
73 Active,
74 Superseded,
75}
76
77#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
79pub enum AnnotationAnchorStatus {
80 #[default]
82 Resolved,
83 Ambiguous { candidate_paths: Vec<String> },
85 Orphaned,
87}
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "lowercase")]
96pub enum AnnotationKind {
97 Constraint,
99 Invariant,
101 Rationale,
103}
104
105#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
107pub enum ContextTarget {
108 File { path: String },
109 State { state_id: StateId },
110}
111
112#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
114pub enum AnnotationScope {
115 File,
116 Symbol {
117 name: String,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
121 resolved_lines: Option<(u32, u32)>,
122 },
123 Lines(u32, u32),
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
127pub enum ContextError {
128 #[error("unsupported context format version {0}")]
129 UnsupportedVersion(u8),
130 #[error("line range start {0} exceeds end {1}")]
131 InvalidLineRange(u32, u32),
132 #[error("symbol name must not be empty")]
133 EmptySymbol,
134 #[error("file target path must not be empty")]
135 EmptyTargetPath,
136 #[error("context target path must be relative, got: {0}")]
137 AbsoluteTargetPath(String),
138 #[error("invalid context target path: {0}")]
139 InvalidTargetPath(String),
140 #[error("state-level guidance must use file scope only")]
141 StateTargetMustUseFileScope,
142 #[error("annotation {0} has no revisions")]
143 MissingRevisions(String),
144 #[error("invalid context encoding: {0}")]
145 InvalidEncoding(String),
146}
147
148versioned_msgpack_blob! {
151 blob: ContextBlob,
152 item: Annotation,
153 field: annotations,
154 error: ContextError,
155 codec_err: InvalidEncoding,
156 version: 2,
157}
158
159impl Annotation {
160 #[allow(clippy::too_many_arguments)]
161 pub fn new(
162 scope: AnnotationScope,
163 kind: AnnotationKind,
164 content: String,
165 tags: Vec<String>,
166 attribution: String,
167 created_at: i64,
168 source_hash: Option<ContentHash>,
169 created_at_state: Option<StateId>,
170 ) -> Self {
171 Self {
172 annotation_id: uuid::Uuid::now_v7().to_string(),
173 scope,
174 status: AnnotationStatus::Active,
175 revisions: vec![AnnotationRevision {
176 revision_id: uuid::Uuid::now_v7().to_string(),
177 kind,
178 content,
179 tags,
180 attribution,
181 created_at,
182 source_hash,
183 created_at_state,
184 }],
185 supersedes_annotation_id: None,
186 supersedes_rewrite_pct: None,
187 visibility: VisibilityTier::default(),
188 resolved_from_discussion: None,
189 anchor_status: AnnotationAnchorStatus::default(),
190 }
191 }
192
193 pub fn current_revision(&self) -> Option<&AnnotationRevision> {
194 self.revisions.last()
195 }
196
197 pub fn current_revision_mut(&mut self) -> Option<&mut AnnotationRevision> {
198 self.revisions.last_mut()
199 }
200
201 #[allow(clippy::too_many_arguments)]
202 pub fn revise(
203 &mut self,
204 kind: AnnotationKind,
205 content: String,
206 tags: Vec<String>,
207 attribution: String,
208 created_at: i64,
209 source_hash: Option<ContentHash>,
210 created_at_state: Option<StateId>,
211 ) -> &AnnotationRevision {
212 self.revisions.push(AnnotationRevision {
213 revision_id: uuid::Uuid::now_v7().to_string(),
214 kind,
215 content,
216 tags,
217 attribution,
218 created_at,
219 source_hash,
220 created_at_state,
221 });
222 self.current_revision().expect("new revision appended")
223 }
224
225 pub fn mark_superseded(&mut self) {
226 self.status = AnnotationStatus::Superseded;
227 }
228
229 pub fn validate(&self) -> Result<(), ContextError> {
230 self.scope.validate()?;
231 if self.annotation_id.is_empty() {
232 return Err(ContextError::InvalidEncoding(
233 "annotation_id must not be empty".to_string(),
234 ));
235 }
236 if self.revisions.is_empty() {
237 return Err(ContextError::MissingRevisions(self.annotation_id.clone()));
238 }
239 for revision in &self.revisions {
240 revision.validate()?;
241 }
242 Ok(())
243 }
244}
245
246impl AnnotationRevision {
247 pub fn validate(&self) -> Result<(), ContextError> {
248 if self.revision_id.is_empty() {
249 return Err(ContextError::InvalidEncoding(
250 "revision_id must not be empty".to_string(),
251 ));
252 }
253 Ok(())
254 }
255}
256
257impl AnnotationKind {
258 pub fn as_str(&self) -> &'static str {
259 match self {
260 Self::Constraint => "constraint",
261 Self::Invariant => "invariant",
262 Self::Rationale => "rationale",
263 }
264 }
265}
266
267impl std::fmt::Display for AnnotationKind {
268 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269 write!(f, "{}", self.as_str())
270 }
271}
272
273impl std::str::FromStr for AnnotationKind {
274 type Err = ContextError;
275
276 fn from_str(value: &str) -> Result<Self, Self::Err> {
277 match value {
278 "constraint" => Ok(Self::Constraint),
279 "invariant" => Ok(Self::Invariant),
280 "rationale" => Ok(Self::Rationale),
281 _ => Err(ContextError::InvalidEncoding(format!(
282 "invalid annotation kind '{value}'"
283 ))),
284 }
285 }
286}
287
288impl ContextTarget {
289 pub fn file(path: impl Into<String>) -> Result<Self, ContextError> {
302 let path = path.into();
303 if path.trim().is_empty() {
304 return Err(ContextError::EmptyTargetPath);
305 }
306 let p = Path::new(&path);
307 if p.is_absolute() {
308 return Err(ContextError::AbsoluteTargetPath(path));
309 }
310 let mut saw_normal = false;
315 for component in p.components() {
316 match component {
317 Component::Normal(_) => saw_normal = true,
318 Component::CurDir => {}
319 Component::ParentDir => {
320 return Err(ContextError::InvalidTargetPath(path));
321 }
322 Component::RootDir | Component::Prefix(_) => {
323 return Err(ContextError::AbsoluteTargetPath(path));
328 }
329 }
330 }
331 if !saw_normal {
332 return Err(ContextError::InvalidTargetPath(path));
333 }
334 Ok(Self::File { path })
335 }
336
337 pub fn state(state_id: StateId) -> Self {
338 Self::State { state_id }
339 }
340
341 pub fn validate_scope(&self, scope: &AnnotationScope) -> Result<(), ContextError> {
342 match self {
343 Self::File { .. } => scope.validate(),
344 Self::State { .. } => {
345 if matches!(scope, AnnotationScope::File) {
346 Ok(())
347 } else {
348 Err(ContextError::StateTargetMustUseFileScope)
349 }
350 }
351 }
352 }
353
354 pub fn storage_path(&self) -> PathBuf {
355 match self {
356 Self::File { path } => Path::new(FILE_TARGET_ROOT).join(path),
357 Self::State { state_id } => {
358 Path::new(STATE_TARGET_ROOT).join(state_id.to_string_full())
359 }
360 }
361 }
362
363 pub fn from_storage_path(path: &Path) -> Option<Self> {
364 let mut components = path.components();
365 match components.next()? {
366 Component::Normal(part) if part == FILE_TARGET_ROOT => {
367 let rest = components.as_path();
368 if rest.as_os_str().is_empty() {
369 None
370 } else {
371 Some(Self::File {
372 path: rest.to_string_lossy().to_string(),
373 })
374 }
375 }
376 Component::Normal(part) if part == STATE_TARGET_ROOT => {
377 let rest = components.as_path();
378 let mut state_components = rest.components();
379 let Component::Normal(id) = state_components.next()? else {
380 return None;
381 };
382 if !state_components.as_path().as_os_str().is_empty() {
383 return None;
384 }
385 StateId::parse(&id.to_string_lossy())
386 .ok()
387 .map(|state_id| Self::State { state_id })
388 }
389 _ => None,
390 }
391 }
392
393 pub fn path(&self) -> Option<&str> {
394 match self {
395 Self::File { path } => Some(path),
396 Self::State { .. } => None,
397 }
398 }
399
400 pub fn state_id(&self) -> Option<StateId> {
401 match self {
402 Self::State { state_id } => Some(*state_id),
403 Self::File { .. } => None,
404 }
405 }
406}
407
408impl AnnotationScope {
409 pub fn validate(&self) -> Result<(), ContextError> {
410 match self {
411 Self::File => Ok(()),
412 Self::Symbol {
413 name,
414 resolved_lines,
415 } => {
416 if name.is_empty() {
417 return Err(ContextError::EmptySymbol);
418 }
419 if let Some((start, end)) = resolved_lines
420 && start > end
421 {
422 return Err(ContextError::InvalidLineRange(*start, *end));
423 }
424 Ok(())
425 }
426 Self::Lines(start, end) => {
427 if start > end {
428 Err(ContextError::InvalidLineRange(*start, *end))
429 } else {
430 Ok(())
431 }
432 }
433 }
434 }
435
436 pub fn matches(&self, other: &Self) -> bool {
437 match (self, other) {
438 (Self::File, Self::File) => true,
439 (Self::Symbol { name: a, .. }, Self::Symbol { name: b, .. }) => a == b,
440 (Self::Lines(a1, a2), Self::Lines(b1, b2)) => a1 == b1 && a2 == b2,
441 _ => false,
442 }
443 }
444
445 pub fn symbol_name(&self) -> Option<&str> {
446 match self {
447 Self::Symbol { name, .. } => Some(name),
448 _ => None,
449 }
450 }
451
452 pub fn line_range(&self) -> Option<(u32, u32)> {
453 match self {
454 Self::Lines(start, end) => Some((*start, *end)),
455 Self::Symbol {
456 resolved_lines: Some((start, end)),
457 ..
458 } => Some((*start, *end)),
459 _ => None,
460 }
461 }
462}
463
464impl std::fmt::Display for AnnotationScope {
465 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466 match self {
467 Self::File => write!(f, "file"),
468 Self::Symbol { name, .. } => write!(f, "symbol:{name}"),
469 Self::Lines(start, end) => write!(f, "lines:{start}-{end}"),
470 }
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
481 fn context_target_accepts_relative_paths() {
482 assert!(ContextTarget::file("src/auth.rs").is_ok());
484 assert!(ContextTarget::file("a/b/c.txt").is_ok());
485 assert!(ContextTarget::file(".gitignore").is_ok());
486 assert!(ContextTarget::file("a").is_ok());
487 assert!(ContextTarget::file("./a").is_ok());
490 }
491
492 #[test]
493 fn context_target_rejects_empty_path() {
494 assert!(matches!(
495 ContextTarget::file(""),
496 Err(ContextError::EmptyTargetPath)
497 ));
498 assert!(matches!(
499 ContextTarget::file(" "),
500 Err(ContextError::EmptyTargetPath)
501 ));
502 }
503
504 #[test]
505 fn context_target_rejects_absolute_path_unix() {
506 let err = ContextTarget::file("/Users/me/repo/src/auth.rs").unwrap_err();
507 assert!(
508 matches!(err, ContextError::AbsoluteTargetPath(ref p) if p == "/Users/me/repo/src/auth.rs"),
509 "got {err:?}"
510 );
511 assert!(matches!(
513 ContextTarget::file("/"),
514 Err(ContextError::AbsoluteTargetPath(_))
515 ));
516 }
517
518 #[test]
519 fn context_target_rejects_parent_escape() {
520 assert!(matches!(
523 ContextTarget::file("../etc/passwd"),
524 Err(ContextError::InvalidTargetPath(_))
525 ));
526 assert!(matches!(
527 ContextTarget::file("src/../../escape"),
528 Err(ContextError::InvalidTargetPath(_))
529 ));
530 }
531
532 #[test]
533 fn context_target_rejects_all_dot_components() {
534 assert!(matches!(
538 ContextTarget::file("."),
539 Err(ContextError::InvalidTargetPath(_))
540 ));
541 assert!(matches!(
542 ContextTarget::file("./."),
543 Err(ContextError::InvalidTargetPath(_))
544 ));
545 }
546
547 #[test]
548 fn roundtrips_revision_with_missing_source_hash_and_present_state() {
549 let created_at_state = StateId::from_bytes([3; 32]);
550 let blob = ContextBlob::new(vec![Annotation::new(
551 AnnotationScope::File,
552 AnnotationKind::Rationale,
553 "Entry point".to_string(),
554 vec!["critical".to_string()],
555 "test@example.com".to_string(),
556 1700000000,
557 None,
558 Some(created_at_state),
559 )]);
560
561 let encoded = blob.encode().unwrap();
562 let decoded = ContextBlob::decode(&encoded).unwrap();
563 let revision = decoded.annotations[0].current_revision().unwrap();
564 assert_eq!(revision.source_hash, None);
565 assert_eq!(revision.created_at_state, Some(created_at_state));
566 }
567
568 #[test]
569 fn roundtrip_serialization() {
570 let blob = ContextBlob::new(vec![Annotation::new(
571 AnnotationScope::File,
572 AnnotationKind::Invariant,
573 "Entry point".to_string(),
574 vec!["constraint".to_string()],
575 "test@example.com".to_string(),
576 1700000000,
577 None,
578 None,
579 )]);
580
581 let bytes = blob.encode().unwrap();
582 let decoded = ContextBlob::decode(&bytes).unwrap();
583 assert_eq!(blob, decoded);
584 }
585
586 #[test]
587 fn legacy_annotation_without_anchor_status_decodes_as_resolved() {
588 #[derive(Serialize)]
589 struct LegacyAnnotation {
590 annotation_id: String,
591 scope: AnnotationScope,
592 status: AnnotationStatus,
593 revisions: Vec<AnnotationRevision>,
594 supersedes_annotation_id: Option<String>,
595 supersedes_rewrite_pct: Option<u32>,
596 visibility: VisibilityTier,
597 resolved_from_discussion: Option<String>,
598 }
599
600 #[derive(Serialize)]
601 struct LegacyContextBlob {
602 format_version: u8,
603 annotations: Vec<LegacyAnnotation>,
604 }
605
606 let revision = AnnotationRevision {
607 revision_id: "legacy-revision".to_string(),
608 kind: AnnotationKind::Invariant,
609 content: "legacy context".to_string(),
610 tags: vec![],
611 attribution: "test@example.com".to_string(),
612 created_at: 1_700_000_000,
613 source_hash: None,
614 created_at_state: None,
615 };
616 let bytes = rmp_serde::to_vec(&LegacyContextBlob {
617 format_version: ContextBlob::FORMAT_VERSION,
618 annotations: vec![LegacyAnnotation {
619 annotation_id: "legacy-annotation".to_string(),
620 scope: AnnotationScope::File,
621 status: AnnotationStatus::Active,
622 revisions: vec![revision],
623 supersedes_annotation_id: None,
624 supersedes_rewrite_pct: None,
625 visibility: VisibilityTier::default(),
626 resolved_from_discussion: None,
627 }],
628 })
629 .unwrap();
630
631 let decoded = ContextBlob::decode(&bytes).unwrap();
632 assert_eq!(
633 decoded.annotations[0].anchor_status,
634 AnnotationAnchorStatus::Resolved
635 );
636 }
637
638 #[test]
639 fn validate_good_blob() {
640 let blob = ContextBlob::new(vec![]);
641 blob.validate().unwrap();
642 }
643
644 #[test]
645 fn validate_bad_version() {
646 let blob = ContextBlob {
647 format_version: 99,
648 annotations: vec![],
649 };
650 assert!(matches!(
651 blob.validate(),
652 Err(ContextError::UnsupportedVersion(99))
653 ));
654 }
655
656 #[test]
657 fn validate_bad_line_range() {
658 let blob = ContextBlob::new(vec![Annotation::new(
659 AnnotationScope::Lines(20, 10),
660 AnnotationKind::Rationale,
661 "bad".to_string(),
662 vec![],
663 "test".to_string(),
664 0,
665 None,
666 None,
667 )]);
668 assert!(matches!(
669 blob.validate(),
670 Err(ContextError::InvalidLineRange(20, 10))
671 ));
672 }
673
674 #[test]
675 fn validate_empty_symbol() {
676 let blob = ContextBlob::new(vec![Annotation::new(
677 AnnotationScope::Symbol {
678 name: String::new(),
679 resolved_lines: None,
680 },
681 AnnotationKind::Rationale,
682 "bad".to_string(),
683 vec![],
684 "test".to_string(),
685 0,
686 None,
687 None,
688 )]);
689 assert!(matches!(blob.validate(), Err(ContextError::EmptySymbol)));
690 }
691
692 #[test]
693 fn scope_matching() {
694 assert!(AnnotationScope::File.matches(&AnnotationScope::File));
695 assert!(
696 AnnotationScope::Symbol {
697 name: "foo".into(),
698 resolved_lines: None
699 }
700 .matches(&AnnotationScope::Symbol {
701 name: "foo".into(),
702 resolved_lines: Some((1, 5))
703 })
704 );
705 assert!(
706 !AnnotationScope::Symbol {
707 name: "foo".into(),
708 resolved_lines: None
709 }
710 .matches(&AnnotationScope::Symbol {
711 name: "bar".into(),
712 resolved_lines: None
713 })
714 );
715 assert!(AnnotationScope::Lines(1, 10).matches(&AnnotationScope::Lines(1, 10)));
716 }
717
718 #[test]
719 fn state_targets_only_allow_file_scope() {
720 let target = ContextTarget::state(StateId::from_bytes([1; 32]));
721 assert!(target.validate_scope(&AnnotationScope::File).is_ok());
722 assert!(matches!(
723 target.validate_scope(&AnnotationScope::Lines(1, 2)),
724 Err(ContextError::StateTargetMustUseFileScope)
725 ));
726 }
727
728 #[test]
729 fn context_target_storage_roundtrip() {
730 let file = ContextTarget::file("src/main.rs").unwrap();
731 assert_eq!(
732 ContextTarget::from_storage_path(&file.storage_path()),
733 Some(file.clone())
734 );
735
736 let state = ContextTarget::state(StateId::from_bytes([2; 32]));
737 assert_eq!(
738 ContextTarget::from_storage_path(&state.storage_path()),
739 Some(state)
740 );
741 }
742
743 #[test]
744 fn context_target_storage_rejects_legacy_direct_paths() {
745 assert_eq!(
746 ContextTarget::from_storage_path(Path::new("src/main.rs")),
747 None
748 );
749 }
750}