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