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