Skip to main content

heddle_object_model/object/
state_context.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Context annotations for files, symbols, line ranges, and broader state guidance.
3
4use 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/// A collection of logical annotations for a single target.
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ContextBlob {
19    pub format_version: u8,
20    pub annotations: Vec<Annotation>,
21}
22
23/// A stable logical annotation with revision history.
24#[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    // --- tail-only optional fields below; new fields go here. ---
35    /// Visibility scope. Pre-W1 annotations have no field on disk; rmp-serde
36    /// fills the default ([`VisibilityTier::Public`]), preserving the
37    /// pre-existing meaning ("annotations are publicly visible").
38    #[serde(default)]
39    pub visibility: VisibilityTier,
40    /// Back-pointer set when this annotation was produced by resolving a
41    /// discussion. Lets viewers jump from the annotation back to the
42    /// discussion that produced it.
43    #[serde(default)]
44    pub resolved_from_discussion: Option<String>,
45    /// Materialized health of the annotation's file anchor. Lifecycle status
46    /// remains separate: an active annotation can need anchor attention.
47    #[serde(default)]
48    pub anchor_status: AnnotationAnchorStatus,
49    /// Live revision tips when concurrent edits have not been resolved.
50    /// Empty means the last revision is the sole current revision.
51    #[serde(default)]
52    pub divergent_revision_ids: Vec<String>,
53}
54
55/// A single revision of a logical annotation.
56#[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    /// BLAKE3 hash of the source bytes at the annotated scope when created.
65    /// For File scope: hash of entire file blob.
66    /// For Symbol/Lines: hash of the relevant byte range.
67    #[serde(default)]
68    pub source_hash: Option<ContentHash>,
69    /// The State this revision was created against.
70    /// Enables retrieving the exact source as it was at annotation time.
71    #[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    /// Deletion tombstone: keep the revision history so a stale replica cannot
80    /// bring this annotation back. A new annotation needs a new identity.
81    Deleted,
82}
83
84/// Snapshot-time resolution state for a file-backed context annotation.
85#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
86pub enum AnnotationAnchorStatus {
87    /// The target path currently resolves, including after a confident move.
88    #[default]
89    Resolved,
90    /// More than one path passed the rename confidence threshold.
91    Ambiguous { candidate_paths: Vec<String> },
92    /// The target disappeared and no path passed the rename threshold.
93    Orphaned,
94}
95
96/// The canonical annotation taxonomy the product surfaces.
97///
98/// `Constraint`, `Invariant`, and `Rationale` are the three kinds of
99/// reasoning we keep alongside code. The lowercase serde names are the
100/// wire/storage vocabulary shared with proto and the web API.
101#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum AnnotationKind {
104    /// A rule the code must obey. Example: "empty scope must return NoScope".
105    Constraint,
106    /// A property that must hold across operations. Example: "state DAG is append-only".
107    Invariant,
108    /// Design decision + reasoning. Example: "thread resolution walks to LCA because…".
109    Rationale,
110}
111
112/// A typed target for context entries.
113#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
114pub enum ContextTarget {
115    File { path: String },
116    State { state_id: StateId },
117}
118
119/// What part of a file an annotation targets.
120#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
121pub enum AnnotationScope {
122    File,
123    Symbol {
124        name: String,
125        /// Line range resolved at annotation creation time via tree-sitter.
126        /// Enables the web UI to show exact code for this symbol.
127        #[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
155// Current encoded format version is 2. Reject anything that isn't the
156// current value — no live deployments to migrate from.
157versioned_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    /// Create an annotation with an explicit audience. Derived annotations must
168    /// carry their source visibility rather than choosing a creation default.
169    #[allow(clippy::too_many_arguments)]
170    pub fn new(
171        scope: AnnotationScope,
172        kind: AnnotationKind,
173        content: String,
174        tags: Vec<String>,
175        attribution: String,
176        created_at: i64,
177        source_hash: Option<ContentHash>,
178        created_at_state: Option<StateId>,
179        visibility: VisibilityTier,
180    ) -> Self {
181        Self {
182            annotation_id: uuid::Uuid::now_v7().to_string(),
183            scope,
184            status: AnnotationStatus::Active,
185            revisions: vec![AnnotationRevision {
186                revision_id: uuid::Uuid::now_v7().to_string(),
187                kind,
188                content,
189                tags,
190                attribution,
191                created_at,
192                source_hash,
193                created_at_state,
194            }],
195            supersedes_annotation_id: None,
196            supersedes_rewrite_pct: None,
197            visibility,
198            resolved_from_discussion: None,
199            anchor_status: AnnotationAnchorStatus::default(),
200            divergent_revision_ids: Vec::new(),
201        }
202    }
203
204    pub fn current_revision(&self) -> Option<&AnnotationRevision> {
205        self.revisions.last()
206    }
207
208    pub fn current_revision_mut(&mut self) -> Option<&mut AnnotationRevision> {
209        self.revisions.last_mut()
210    }
211
212    #[allow(clippy::too_many_arguments)]
213    pub fn revise(
214        &mut self,
215        kind: AnnotationKind,
216        content: String,
217        tags: Vec<String>,
218        attribution: String,
219        created_at: i64,
220        source_hash: Option<ContentHash>,
221        created_at_state: Option<StateId>,
222    ) -> &AnnotationRevision {
223        self.revisions.push(AnnotationRevision {
224            revision_id: uuid::Uuid::now_v7().to_string(),
225            kind,
226            content,
227            tags,
228            attribution,
229            created_at,
230            source_hash,
231            created_at_state,
232        });
233        self.divergent_revision_ids.clear();
234        &self.revisions[self.revisions.len() - 1]
235    }
236
237    pub fn mark_superseded(&mut self) {
238        self.status = AnnotationStatus::Superseded;
239    }
240
241    pub fn validate(&self) -> Result<(), ContextError> {
242        self.scope.validate()?;
243        if self.annotation_id.is_empty() {
244            return Err(ContextError::InvalidEncoding(
245                "annotation_id must not be empty".to_string(),
246            ));
247        }
248        if self.revisions.is_empty() {
249            return Err(ContextError::MissingRevisions(self.annotation_id.clone()));
250        }
251        for revision in &self.revisions {
252            revision.validate()?;
253        }
254        if self.divergent_revision_ids.len() == 1
255            || self.divergent_revision_ids.len() > 64
256            || self
257                .divergent_revision_ids
258                .iter()
259                .collect::<std::collections::BTreeSet<_>>()
260                .len()
261                != self.divergent_revision_ids.len()
262            || self.divergent_revision_ids.iter().any(|id| {
263                !self
264                    .revisions
265                    .iter()
266                    .any(|revision| revision.revision_id == *id)
267            })
268        {
269            return Err(ContextError::InvalidEncoding(format!(
270                "invalid divergent revision frontier for {}",
271                self.annotation_id
272            )));
273        }
274        Ok(())
275    }
276
277    pub fn current_revision_ids(&self) -> Vec<&str> {
278        if self.divergent_revision_ids.is_empty() {
279            self.current_revision()
280                .map(|revision| vec![revision.revision_id.as_str()])
281                .unwrap_or_default()
282        } else {
283            self.divergent_revision_ids
284                .iter()
285                .map(String::as_str)
286                .collect()
287        }
288    }
289}
290
291impl AnnotationRevision {
292    pub fn validate(&self) -> Result<(), ContextError> {
293        if self.revision_id.is_empty() {
294            return Err(ContextError::InvalidEncoding(
295                "revision_id must not be empty".to_string(),
296            ));
297        }
298        Ok(())
299    }
300}
301
302impl AnnotationKind {
303    pub fn as_str(&self) -> &'static str {
304        match self {
305            Self::Constraint => "constraint",
306            Self::Invariant => "invariant",
307            Self::Rationale => "rationale",
308        }
309    }
310}
311
312impl std::fmt::Display for AnnotationKind {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        write!(f, "{}", self.as_str())
315    }
316}
317
318impl std::str::FromStr for AnnotationKind {
319    type Err = ContextError;
320
321    fn from_str(value: &str) -> Result<Self, Self::Err> {
322        match value {
323            "constraint" => Ok(Self::Constraint),
324            "invariant" => Ok(Self::Invariant),
325            "rationale" => Ok(Self::Rationale),
326            _ => Err(ContextError::InvalidEncoding(format!(
327                "invalid annotation kind '{value}'"
328            ))),
329        }
330    }
331}
332
333impl ContextTarget {
334    /// Construct a file-scope target. The path must be non-empty,
335    /// relative, and walkable — it's stored inside the context tree
336    /// under `__files/<path>`, and the downstream writer's
337    /// `split_path` helper only understands `Component::Normal` (no
338    /// `RootDir`, no `ParentDir`, no `CurDir`-only trails).
339    ///
340    /// Previously this accepted any non-empty string, which meant
341    /// absolute paths like `/Users/me/repo/src/auth.rs` got all the
342    /// way to `Repository::set_context_blob` before failing with a
343    /// cryptic `"empty path"` error deep in the tree-insert routine.
344    /// Rejecting here turns that into a clear
345    /// `AbsoluteTargetPath`/`InvalidTargetPath` at the callsite.
346    pub fn file(path: impl Into<String>) -> Result<Self, ContextError> {
347        let path = path.into();
348        if path.trim().is_empty() {
349            return Err(ContextError::EmptyTargetPath);
350        }
351        let p = Path::new(&path);
352        if p.is_absolute() {
353            return Err(ContextError::AbsoluteTargetPath(path));
354        }
355        // Walk components: reject `..` anywhere (would let the path
356        // escape `__files/`), and require at least one `Normal`
357        // component (rejects paths like `.`, `./.`, or strings whose
358        // every component is `CurDir`).
359        let mut saw_normal = false;
360        for component in p.components() {
361            match component {
362                Component::Normal(_) => saw_normal = true,
363                Component::CurDir => {}
364                Component::ParentDir => {
365                    return Err(ContextError::InvalidTargetPath(path));
366                }
367                Component::RootDir | Component::Prefix(_) => {
368                    // `is_absolute` above already catches the typical
369                    // cases on both Unix and Windows, but belt-and-
370                    // braces: if a Prefix or RootDir sneaks through
371                    // on some platform, still reject.
372                    return Err(ContextError::AbsoluteTargetPath(path));
373                }
374            }
375        }
376        if !saw_normal {
377            return Err(ContextError::InvalidTargetPath(path));
378        }
379        Ok(Self::File { path })
380    }
381
382    pub fn state(state_id: StateId) -> Self {
383        Self::State { state_id }
384    }
385
386    pub fn validate_scope(&self, scope: &AnnotationScope) -> Result<(), ContextError> {
387        match self {
388            Self::File { .. } => scope.validate(),
389            Self::State { .. } => {
390                if matches!(scope, AnnotationScope::File) {
391                    Ok(())
392                } else {
393                    Err(ContextError::StateTargetMustUseFileScope)
394                }
395            }
396        }
397    }
398
399    pub fn storage_path(&self) -> PathBuf {
400        match self {
401            Self::File { path } => Path::new(FILE_TARGET_ROOT).join(path),
402            Self::State { state_id } => {
403                Path::new(STATE_TARGET_ROOT).join(state_id.to_string_full())
404            }
405        }
406    }
407
408    pub fn from_storage_path(path: &Path) -> Option<Self> {
409        let mut components = path.components();
410        match components.next()? {
411            Component::Normal(part) if part == FILE_TARGET_ROOT => {
412                let rest = components.as_path();
413                if rest.as_os_str().is_empty() {
414                    None
415                } else {
416                    Some(Self::File {
417                        path: rest.to_string_lossy().to_string(),
418                    })
419                }
420            }
421            Component::Normal(part) if part == STATE_TARGET_ROOT => {
422                let rest = components.as_path();
423                let mut state_components = rest.components();
424                let Component::Normal(id) = state_components.next()? else {
425                    return None;
426                };
427                if !state_components.as_path().as_os_str().is_empty() {
428                    return None;
429                }
430                StateId::parse(&id.to_string_lossy())
431                    .ok()
432                    .map(|state_id| Self::State { state_id })
433            }
434            _ => None,
435        }
436    }
437
438    pub fn path(&self) -> Option<&str> {
439        match self {
440            Self::File { path } => Some(path),
441            Self::State { .. } => None,
442        }
443    }
444
445    pub fn state_id(&self) -> Option<StateId> {
446        match self {
447            Self::State { state_id } => Some(*state_id),
448            Self::File { .. } => None,
449        }
450    }
451}
452
453impl AnnotationScope {
454    pub fn validate(&self) -> Result<(), ContextError> {
455        match self {
456            Self::File => Ok(()),
457            Self::Symbol {
458                name,
459                resolved_lines,
460            } => {
461                if name.is_empty() {
462                    return Err(ContextError::EmptySymbol);
463                }
464                if let Some((start, end)) = resolved_lines
465                    && start > end
466                {
467                    return Err(ContextError::InvalidLineRange(*start, *end));
468                }
469                Ok(())
470            }
471            Self::Lines(start, end) => {
472                if start > end {
473                    Err(ContextError::InvalidLineRange(*start, *end))
474                } else {
475                    Ok(())
476                }
477            }
478        }
479    }
480
481    pub fn matches(&self, other: &Self) -> bool {
482        match (self, other) {
483            (Self::File, Self::File) => true,
484            (Self::Symbol { name: a, .. }, Self::Symbol { name: b, .. }) => a == b,
485            (Self::Lines(a1, a2), Self::Lines(b1, b2)) => a1 == b1 && a2 == b2,
486            _ => false,
487        }
488    }
489
490    pub fn symbol_name(&self) -> Option<&str> {
491        match self {
492            Self::Symbol { name, .. } => Some(name),
493            _ => None,
494        }
495    }
496
497    pub fn line_range(&self) -> Option<(u32, u32)> {
498        match self {
499            Self::Lines(start, end) => Some((*start, *end)),
500            Self::Symbol {
501                resolved_lines: Some((start, end)),
502                ..
503            } => Some((*start, *end)),
504            _ => None,
505        }
506    }
507}
508
509impl std::fmt::Display for AnnotationScope {
510    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
511        match self {
512            Self::File => write!(f, "file"),
513            Self::Symbol { name, .. } => write!(f, "symbol:{name}"),
514            Self::Lines(start, end) => write!(f, "lines:{start}-{end}"),
515        }
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn explicit_visibility_survives_revision_and_blob_round_trip() {
525        for visibility in [
526            VisibilityTier::Public,
527            VisibilityTier::Internal,
528            VisibilityTier::TeamScoped {
529                team_id: "engineering".into(),
530            },
531            VisibilityTier::Restricted {
532                scope_label: "evaluation".into(),
533            },
534            VisibilityTier::Private {
535                scope_label: "evaluation".into(),
536            },
537        ] {
538            let mut annotation = Annotation::new(
539                AnnotationScope::File,
540                AnnotationKind::Invariant,
541                "decision".into(),
542                vec![],
543                "test@example.com".into(),
544                1,
545                None,
546                None,
547                visibility.clone(),
548            );
549            annotation.revise(
550                AnnotationKind::Invariant,
551                "revised decision".into(),
552                vec![],
553                "test@example.com".into(),
554                2,
555                None,
556                None,
557            );
558            let decoded =
559                ContextBlob::decode(&ContextBlob::new(vec![annotation]).encode().expect("encode"))
560                    .expect("decode");
561            assert_eq!(decoded.annotations[0].visibility, visibility);
562            assert_eq!(decoded.annotations[0].revisions.len(), 2);
563        }
564    }
565
566    // --- ContextTarget::file validation --------------------------------
567
568    #[test]
569    fn context_target_accepts_relative_paths() {
570        // Plain relative, nested, and dotfile forms should all pass.
571        assert!(ContextTarget::file("src/auth.rs").is_ok());
572        assert!(ContextTarget::file("a/b/c.txt").is_ok());
573        assert!(ContextTarget::file(".gitignore").is_ok());
574        assert!(ContextTarget::file("a").is_ok());
575        // A leading `./` is pure noise; still accepted (the CurDir
576        // components are ignored, and `a` is a Normal component).
577        assert!(ContextTarget::file("./a").is_ok());
578    }
579
580    #[test]
581    fn context_target_rejects_empty_path() {
582        assert!(matches!(
583            ContextTarget::file(""),
584            Err(ContextError::EmptyTargetPath)
585        ));
586        assert!(matches!(
587            ContextTarget::file("   "),
588            Err(ContextError::EmptyTargetPath)
589        ));
590    }
591
592    #[test]
593    fn context_target_rejects_absolute_path_unix() {
594        let err = ContextTarget::file("/Users/me/repo/src/auth.rs").unwrap_err();
595        assert!(
596            matches!(err, ContextError::AbsoluteTargetPath(ref p) if p == "/Users/me/repo/src/auth.rs"),
597            "got {err:?}"
598        );
599        // Root alone also absolute.
600        assert!(matches!(
601            ContextTarget::file("/"),
602            Err(ContextError::AbsoluteTargetPath(_))
603        ));
604    }
605
606    #[test]
607    fn context_target_rejects_parent_escape() {
608        // `..` anywhere would let a writer escape `__files/` inside
609        // the context tree.
610        assert!(matches!(
611            ContextTarget::file("../etc/passwd"),
612            Err(ContextError::InvalidTargetPath(_))
613        ));
614        assert!(matches!(
615            ContextTarget::file("src/../../escape"),
616            Err(ContextError::InvalidTargetPath(_))
617        ));
618    }
619
620    #[test]
621    fn context_target_rejects_all_dot_components() {
622        // A path made entirely of `.`/`./.` is non-empty under the
623        // old check but has no Normal component to write under, so
624        // downstream writes would fail cryptically. Catch it here.
625        assert!(matches!(
626            ContextTarget::file("."),
627            Err(ContextError::InvalidTargetPath(_))
628        ));
629        assert!(matches!(
630            ContextTarget::file("./."),
631            Err(ContextError::InvalidTargetPath(_))
632        ));
633    }
634
635    #[test]
636    fn roundtrips_revision_with_missing_source_hash_and_present_state() {
637        let created_at_state = StateId::from_bytes([3; 32]);
638        let blob = ContextBlob::new(vec![Annotation::new(
639            AnnotationScope::File,
640            AnnotationKind::Rationale,
641            "Entry point".to_string(),
642            vec!["critical".to_string()],
643            "test@example.com".to_string(),
644            1700000000,
645            None,
646            Some(created_at_state),
647            crate::object::VisibilityTier::Public,
648        )]);
649
650        let encoded = blob.encode().unwrap();
651        let decoded = ContextBlob::decode(&encoded).unwrap();
652        let revision = decoded.annotations[0].current_revision().unwrap();
653        assert_eq!(revision.source_hash, None);
654        assert_eq!(revision.created_at_state, Some(created_at_state));
655    }
656
657    #[test]
658    fn roundtrip_serialization() {
659        let blob = ContextBlob::new(vec![Annotation::new(
660            AnnotationScope::File,
661            AnnotationKind::Invariant,
662            "Entry point".to_string(),
663            vec!["constraint".to_string()],
664            "test@example.com".to_string(),
665            1700000000,
666            None,
667            None,
668            crate::object::VisibilityTier::Public,
669        )]);
670
671        let bytes = blob.encode().unwrap();
672        let decoded = ContextBlob::decode(&bytes).unwrap();
673        assert_eq!(blob, decoded);
674    }
675
676    #[test]
677    fn legacy_annotation_without_anchor_status_decodes_as_resolved() {
678        #[derive(Serialize)]
679        struct LegacyAnnotation {
680            annotation_id: String,
681            scope: AnnotationScope,
682            status: AnnotationStatus,
683            revisions: Vec<AnnotationRevision>,
684            supersedes_annotation_id: Option<String>,
685            supersedes_rewrite_pct: Option<u32>,
686            visibility: VisibilityTier,
687            resolved_from_discussion: Option<String>,
688        }
689
690        #[derive(Serialize)]
691        struct LegacyContextBlob {
692            format_version: u8,
693            annotations: Vec<LegacyAnnotation>,
694        }
695
696        let revision = AnnotationRevision {
697            revision_id: "legacy-revision".to_string(),
698            kind: AnnotationKind::Invariant,
699            content: "legacy context".to_string(),
700            tags: vec![],
701            attribution: "test@example.com".to_string(),
702            created_at: 1_700_000_000,
703            source_hash: None,
704            created_at_state: None,
705        };
706        let bytes = rmp_serde::to_vec(&LegacyContextBlob {
707            format_version: ContextBlob::FORMAT_VERSION,
708            annotations: vec![LegacyAnnotation {
709                annotation_id: "legacy-annotation".to_string(),
710                scope: AnnotationScope::File,
711                status: AnnotationStatus::Active,
712                revisions: vec![revision],
713                supersedes_annotation_id: None,
714                supersedes_rewrite_pct: None,
715                visibility: VisibilityTier::default(),
716                resolved_from_discussion: None,
717            }],
718        })
719        .unwrap();
720
721        let decoded = ContextBlob::decode(&bytes).unwrap();
722        assert_eq!(
723            decoded.annotations[0].anchor_status,
724            AnnotationAnchorStatus::Resolved
725        );
726    }
727
728    #[test]
729    fn validate_good_blob() {
730        let blob = ContextBlob::new(vec![]);
731        blob.validate().unwrap();
732    }
733
734    #[test]
735    fn validate_bad_version() {
736        let blob = ContextBlob {
737            format_version: 99,
738            annotations: vec![],
739        };
740        assert!(matches!(
741            blob.validate(),
742            Err(ContextError::UnsupportedVersion(99))
743        ));
744    }
745
746    #[test]
747    fn validate_bad_line_range() {
748        let blob = ContextBlob::new(vec![Annotation::new(
749            AnnotationScope::Lines(20, 10),
750            AnnotationKind::Rationale,
751            "bad".to_string(),
752            vec![],
753            "test".to_string(),
754            0,
755            None,
756            None,
757            crate::object::VisibilityTier::Public,
758        )]);
759        assert!(matches!(
760            blob.validate(),
761            Err(ContextError::InvalidLineRange(20, 10))
762        ));
763    }
764
765    #[test]
766    fn validate_empty_symbol() {
767        let blob = ContextBlob::new(vec![Annotation::new(
768            AnnotationScope::Symbol {
769                name: String::new(),
770                resolved_lines: None,
771            },
772            AnnotationKind::Rationale,
773            "bad".to_string(),
774            vec![],
775            "test".to_string(),
776            0,
777            None,
778            None,
779            crate::object::VisibilityTier::Public,
780        )]);
781        assert!(matches!(blob.validate(), Err(ContextError::EmptySymbol)));
782    }
783
784    #[test]
785    fn scope_matching() {
786        assert!(AnnotationScope::File.matches(&AnnotationScope::File));
787        assert!(
788            AnnotationScope::Symbol {
789                name: "foo".into(),
790                resolved_lines: None
791            }
792            .matches(&AnnotationScope::Symbol {
793                name: "foo".into(),
794                resolved_lines: Some((1, 5))
795            })
796        );
797        assert!(
798            !AnnotationScope::Symbol {
799                name: "foo".into(),
800                resolved_lines: None
801            }
802            .matches(&AnnotationScope::Symbol {
803                name: "bar".into(),
804                resolved_lines: None
805            })
806        );
807        assert!(AnnotationScope::Lines(1, 10).matches(&AnnotationScope::Lines(1, 10)));
808    }
809
810    #[test]
811    fn state_targets_only_allow_file_scope() {
812        let target = ContextTarget::state(StateId::from_bytes([1; 32]));
813        assert!(target.validate_scope(&AnnotationScope::File).is_ok());
814        assert!(matches!(
815            target.validate_scope(&AnnotationScope::Lines(1, 2)),
816            Err(ContextError::StateTargetMustUseFileScope)
817        ));
818    }
819
820    #[test]
821    fn context_target_storage_roundtrip() {
822        let file = ContextTarget::file("src/main.rs").unwrap();
823        assert_eq!(
824            ContextTarget::from_storage_path(&file.storage_path()),
825            Some(file.clone())
826        );
827
828        let state = ContextTarget::state(StateId::from_bytes([2; 32]));
829        assert_eq!(
830            ContextTarget::from_storage_path(&state.storage_path()),
831            Some(state)
832        );
833    }
834
835    #[test]
836    fn context_target_storage_rejects_legacy_direct_paths() {
837        assert_eq!(
838            ContextTarget::from_storage_path(Path::new("src/main.rs")),
839            None
840        );
841    }
842}