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 from_storage_path(path: &Path) -> Option<Self> {
347 let mut components = path.components();
348 match components.next()? {
349 Component::Normal(part) if part == FILE_TARGET_ROOT => {
350 let rest = components.as_path();
351 if rest.as_os_str().is_empty() {
352 None
353 } else {
354 Some(Self::File {
355 path: rest.to_string_lossy().to_string(),
356 })
357 }
358 }
359 Component::Normal(part) if part == STATE_TARGET_ROOT => {
360 let rest = components.as_path();
361 let mut state_components = rest.components();
362 let Component::Normal(id) = state_components.next()? else {
363 return None;
364 };
365 if !state_components.as_path().as_os_str().is_empty() {
366 return None;
367 }
368 ChangeId::parse(&id.to_string_lossy())
369 .ok()
370 .map(|change_id| Self::State { change_id })
371 }
372 _ => None,
373 }
374 }
375
376 pub fn path(&self) -> Option<&str> {
377 match self {
378 Self::File { path } => Some(path),
379 Self::State { .. } => None,
380 }
381 }
382
383 pub fn state_id(&self) -> Option<ChangeId> {
384 match self {
385 Self::State { change_id } => Some(*change_id),
386 Self::File { .. } => None,
387 }
388 }
389}
390
391impl AnnotationScope {
392 pub fn validate(&self) -> Result<(), ContextError> {
393 match self {
394 Self::File => Ok(()),
395 Self::Symbol {
396 name,
397 resolved_lines,
398 } => {
399 if name.is_empty() {
400 return Err(ContextError::EmptySymbol);
401 }
402 if let Some((start, end)) = resolved_lines
403 && start > end
404 {
405 return Err(ContextError::InvalidLineRange(*start, *end));
406 }
407 Ok(())
408 }
409 Self::Lines(start, end) => {
410 if start > end {
411 Err(ContextError::InvalidLineRange(*start, *end))
412 } else {
413 Ok(())
414 }
415 }
416 }
417 }
418
419 pub fn matches(&self, other: &Self) -> bool {
420 match (self, other) {
421 (Self::File, Self::File) => true,
422 (Self::Symbol { name: a, .. }, Self::Symbol { name: b, .. }) => a == b,
423 (Self::Lines(a1, a2), Self::Lines(b1, b2)) => a1 == b1 && a2 == b2,
424 _ => false,
425 }
426 }
427
428 pub fn symbol_name(&self) -> Option<&str> {
429 match self {
430 Self::Symbol { name, .. } => Some(name),
431 _ => None,
432 }
433 }
434
435 pub fn line_range(&self) -> Option<(u32, u32)> {
436 match self {
437 Self::Lines(start, end) => Some((*start, *end)),
438 Self::Symbol {
439 resolved_lines: Some((start, end)),
440 ..
441 } => Some((*start, *end)),
442 _ => None,
443 }
444 }
445}
446
447impl std::fmt::Display for AnnotationScope {
448 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449 match self {
450 Self::File => write!(f, "file"),
451 Self::Symbol { name, .. } => write!(f, "symbol:{name}"),
452 Self::Lines(start, end) => write!(f, "lines:{start}-{end}"),
453 }
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 #[test]
464 fn context_target_accepts_relative_paths() {
465 assert!(ContextTarget::file("src/auth.rs").is_ok());
467 assert!(ContextTarget::file("a/b/c.txt").is_ok());
468 assert!(ContextTarget::file(".gitignore").is_ok());
469 assert!(ContextTarget::file("a").is_ok());
470 assert!(ContextTarget::file("./a").is_ok());
473 }
474
475 #[test]
476 fn context_target_rejects_empty_path() {
477 assert!(matches!(
478 ContextTarget::file(""),
479 Err(ContextError::EmptyTargetPath)
480 ));
481 assert!(matches!(
482 ContextTarget::file(" "),
483 Err(ContextError::EmptyTargetPath)
484 ));
485 }
486
487 #[test]
488 fn context_target_rejects_absolute_path_unix() {
489 let err = ContextTarget::file("/Users/me/repo/src/auth.rs").unwrap_err();
490 assert!(
491 matches!(err, ContextError::AbsoluteTargetPath(ref p) if p == "/Users/me/repo/src/auth.rs"),
492 "got {err:?}"
493 );
494 assert!(matches!(
496 ContextTarget::file("/"),
497 Err(ContextError::AbsoluteTargetPath(_))
498 ));
499 }
500
501 #[test]
502 fn context_target_rejects_parent_escape() {
503 assert!(matches!(
506 ContextTarget::file("../etc/passwd"),
507 Err(ContextError::InvalidTargetPath(_))
508 ));
509 assert!(matches!(
510 ContextTarget::file("src/../../escape"),
511 Err(ContextError::InvalidTargetPath(_))
512 ));
513 }
514
515 #[test]
516 fn context_target_rejects_all_dot_components() {
517 assert!(matches!(
521 ContextTarget::file("."),
522 Err(ContextError::InvalidTargetPath(_))
523 ));
524 assert!(matches!(
525 ContextTarget::file("./."),
526 Err(ContextError::InvalidTargetPath(_))
527 ));
528 }
529
530 #[test]
531 fn roundtrips_revision_with_missing_source_hash_and_present_state() {
532 let created_at_state = ChangeId::generate();
533 let blob = ContextBlob::new(vec![Annotation::new(
534 AnnotationScope::File,
535 AnnotationKind::Rationale,
536 "Entry point".to_string(),
537 vec!["critical".to_string()],
538 "test@example.com".to_string(),
539 1700000000,
540 None,
541 Some(created_at_state),
542 )]);
543
544 let encoded = blob.encode().unwrap();
545 let decoded = ContextBlob::decode(&encoded).unwrap();
546 let revision = decoded.annotations[0].current_revision().unwrap();
547 assert_eq!(revision.source_hash, None);
548 assert_eq!(revision.created_at_state, Some(created_at_state));
549 }
550
551 #[test]
552 fn roundtrip_serialization() {
553 let blob = ContextBlob::new(vec![Annotation::new(
554 AnnotationScope::File,
555 AnnotationKind::Invariant,
556 "Entry point".to_string(),
557 vec!["constraint".to_string()],
558 "test@example.com".to_string(),
559 1700000000,
560 None,
561 None,
562 )]);
563
564 let bytes = blob.encode().unwrap();
565 let decoded = ContextBlob::decode(&bytes).unwrap();
566 assert_eq!(blob, decoded);
567 }
568
569 #[test]
570 fn validate_good_blob() {
571 let blob = ContextBlob::new(vec![]);
572 blob.validate().unwrap();
573 }
574
575 #[test]
576 fn validate_bad_version() {
577 let blob = ContextBlob {
578 format_version: 99,
579 annotations: vec![],
580 };
581 assert!(matches!(
582 blob.validate(),
583 Err(ContextError::UnsupportedVersion(99))
584 ));
585 }
586
587 #[test]
588 fn validate_bad_line_range() {
589 let blob = ContextBlob::new(vec![Annotation::new(
590 AnnotationScope::Lines(20, 10),
591 AnnotationKind::Rationale,
592 "bad".to_string(),
593 vec![],
594 "test".to_string(),
595 0,
596 None,
597 None,
598 )]);
599 assert!(matches!(
600 blob.validate(),
601 Err(ContextError::InvalidLineRange(20, 10))
602 ));
603 }
604
605 #[test]
606 fn validate_empty_symbol() {
607 let blob = ContextBlob::new(vec![Annotation::new(
608 AnnotationScope::Symbol {
609 name: String::new(),
610 resolved_lines: None,
611 },
612 AnnotationKind::Rationale,
613 "bad".to_string(),
614 vec![],
615 "test".to_string(),
616 0,
617 None,
618 None,
619 )]);
620 assert!(matches!(blob.validate(), Err(ContextError::EmptySymbol)));
621 }
622
623 #[test]
624 fn scope_matching() {
625 assert!(AnnotationScope::File.matches(&AnnotationScope::File));
626 assert!(
627 AnnotationScope::Symbol {
628 name: "foo".into(),
629 resolved_lines: None
630 }
631 .matches(&AnnotationScope::Symbol {
632 name: "foo".into(),
633 resolved_lines: Some((1, 5))
634 })
635 );
636 assert!(
637 !AnnotationScope::Symbol {
638 name: "foo".into(),
639 resolved_lines: None
640 }
641 .matches(&AnnotationScope::Symbol {
642 name: "bar".into(),
643 resolved_lines: None
644 })
645 );
646 assert!(AnnotationScope::Lines(1, 10).matches(&AnnotationScope::Lines(1, 10)));
647 }
648
649 #[test]
650 fn state_targets_only_allow_file_scope() {
651 let target = ContextTarget::state(ChangeId::generate());
652 assert!(target.validate_scope(&AnnotationScope::File).is_ok());
653 assert!(matches!(
654 target.validate_scope(&AnnotationScope::Lines(1, 2)),
655 Err(ContextError::StateTargetMustUseFileScope)
656 ));
657 }
658
659 #[test]
660 fn context_target_storage_roundtrip() {
661 let file = ContextTarget::file("src/main.rs").unwrap();
662 assert_eq!(
663 ContextTarget::from_storage_path(&file.storage_path()),
664 Some(file.clone())
665 );
666
667 let state = ContextTarget::state(ChangeId::generate());
668 assert_eq!(
669 ContextTarget::from_storage_path(&state.storage_path()),
670 Some(state)
671 );
672 }
673
674 #[test]
675 fn context_target_storage_rejects_legacy_direct_paths() {
676 assert_eq!(
677 ContextTarget::from_storage_path(Path::new("src/main.rs")),
678 None
679 );
680 }
681}