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    #[allow(clippy::too_many_arguments)]
168    pub fn new(
169        scope: AnnotationScope,
170        kind: AnnotationKind,
171        content: String,
172        tags: Vec<String>,
173        attribution: String,
174        created_at: i64,
175        source_hash: Option<ContentHash>,
176        created_at_state: Option<StateId>,
177    ) -> Self {
178        Self {
179            annotation_id: uuid::Uuid::now_v7().to_string(),
180            scope,
181            status: AnnotationStatus::Active,
182            revisions: vec![AnnotationRevision {
183                revision_id: uuid::Uuid::now_v7().to_string(),
184                kind,
185                content,
186                tags,
187                attribution,
188                created_at,
189                source_hash,
190                created_at_state,
191            }],
192            supersedes_annotation_id: None,
193            supersedes_rewrite_pct: None,
194            visibility: VisibilityTier::default(),
195            resolved_from_discussion: None,
196            anchor_status: AnnotationAnchorStatus::default(),
197            divergent_revision_ids: Vec::new(),
198        }
199    }
200
201    pub fn current_revision(&self) -> Option<&AnnotationRevision> {
202        self.revisions.last()
203    }
204
205    pub fn current_revision_mut(&mut self) -> Option<&mut AnnotationRevision> {
206        self.revisions.last_mut()
207    }
208
209    #[allow(clippy::too_many_arguments)]
210    pub fn revise(
211        &mut self,
212        kind: AnnotationKind,
213        content: String,
214        tags: Vec<String>,
215        attribution: String,
216        created_at: i64,
217        source_hash: Option<ContentHash>,
218        created_at_state: Option<StateId>,
219    ) -> &AnnotationRevision {
220        self.revisions.push(AnnotationRevision {
221            revision_id: uuid::Uuid::now_v7().to_string(),
222            kind,
223            content,
224            tags,
225            attribution,
226            created_at,
227            source_hash,
228            created_at_state,
229        });
230        self.divergent_revision_ids.clear();
231        &self.revisions[self.revisions.len() - 1]
232    }
233
234    pub fn mark_superseded(&mut self) {
235        self.status = AnnotationStatus::Superseded;
236    }
237
238    pub fn validate(&self) -> Result<(), ContextError> {
239        self.scope.validate()?;
240        if self.annotation_id.is_empty() {
241            return Err(ContextError::InvalidEncoding(
242                "annotation_id must not be empty".to_string(),
243            ));
244        }
245        if self.revisions.is_empty() {
246            return Err(ContextError::MissingRevisions(self.annotation_id.clone()));
247        }
248        for revision in &self.revisions {
249            revision.validate()?;
250        }
251        if self.divergent_revision_ids.len() == 1
252            || self.divergent_revision_ids.len() > 64
253            || self
254                .divergent_revision_ids
255                .iter()
256                .collect::<std::collections::BTreeSet<_>>()
257                .len()
258                != self.divergent_revision_ids.len()
259            || self.divergent_revision_ids.iter().any(|id| {
260                !self
261                    .revisions
262                    .iter()
263                    .any(|revision| revision.revision_id == *id)
264            })
265        {
266            return Err(ContextError::InvalidEncoding(format!(
267                "invalid divergent revision frontier for {}",
268                self.annotation_id
269            )));
270        }
271        Ok(())
272    }
273
274    pub fn current_revision_ids(&self) -> Vec<&str> {
275        if self.divergent_revision_ids.is_empty() {
276            self.current_revision()
277                .map(|revision| vec![revision.revision_id.as_str()])
278                .unwrap_or_default()
279        } else {
280            self.divergent_revision_ids
281                .iter()
282                .map(String::as_str)
283                .collect()
284        }
285    }
286}
287
288impl AnnotationRevision {
289    pub fn validate(&self) -> Result<(), ContextError> {
290        if self.revision_id.is_empty() {
291            return Err(ContextError::InvalidEncoding(
292                "revision_id must not be empty".to_string(),
293            ));
294        }
295        Ok(())
296    }
297}
298
299impl AnnotationKind {
300    pub fn as_str(&self) -> &'static str {
301        match self {
302            Self::Constraint => "constraint",
303            Self::Invariant => "invariant",
304            Self::Rationale => "rationale",
305        }
306    }
307}
308
309impl std::fmt::Display for AnnotationKind {
310    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311        write!(f, "{}", self.as_str())
312    }
313}
314
315impl std::str::FromStr for AnnotationKind {
316    type Err = ContextError;
317
318    fn from_str(value: &str) -> Result<Self, Self::Err> {
319        match value {
320            "constraint" => Ok(Self::Constraint),
321            "invariant" => Ok(Self::Invariant),
322            "rationale" => Ok(Self::Rationale),
323            _ => Err(ContextError::InvalidEncoding(format!(
324                "invalid annotation kind '{value}'"
325            ))),
326        }
327    }
328}
329
330impl ContextTarget {
331    /// Construct a file-scope target. The path must be non-empty,
332    /// relative, and walkable — it's stored inside the context tree
333    /// under `__files/<path>`, and the downstream writer's
334    /// `split_path` helper only understands `Component::Normal` (no
335    /// `RootDir`, no `ParentDir`, no `CurDir`-only trails).
336    ///
337    /// Previously this accepted any non-empty string, which meant
338    /// absolute paths like `/Users/me/repo/src/auth.rs` got all the
339    /// way to `Repository::set_context_blob` before failing with a
340    /// cryptic `"empty path"` error deep in the tree-insert routine.
341    /// Rejecting here turns that into a clear
342    /// `AbsoluteTargetPath`/`InvalidTargetPath` at the callsite.
343    pub fn file(path: impl Into<String>) -> Result<Self, ContextError> {
344        let path = path.into();
345        if path.trim().is_empty() {
346            return Err(ContextError::EmptyTargetPath);
347        }
348        let p = Path::new(&path);
349        if p.is_absolute() {
350            return Err(ContextError::AbsoluteTargetPath(path));
351        }
352        // Walk components: reject `..` anywhere (would let the path
353        // escape `__files/`), and require at least one `Normal`
354        // component (rejects paths like `.`, `./.`, or strings whose
355        // every component is `CurDir`).
356        let mut saw_normal = false;
357        for component in p.components() {
358            match component {
359                Component::Normal(_) => saw_normal = true,
360                Component::CurDir => {}
361                Component::ParentDir => {
362                    return Err(ContextError::InvalidTargetPath(path));
363                }
364                Component::RootDir | Component::Prefix(_) => {
365                    // `is_absolute` above already catches the typical
366                    // cases on both Unix and Windows, but belt-and-
367                    // braces: if a Prefix or RootDir sneaks through
368                    // on some platform, still reject.
369                    return Err(ContextError::AbsoluteTargetPath(path));
370                }
371            }
372        }
373        if !saw_normal {
374            return Err(ContextError::InvalidTargetPath(path));
375        }
376        Ok(Self::File { path })
377    }
378
379    pub fn state(state_id: StateId) -> Self {
380        Self::State { state_id }
381    }
382
383    pub fn validate_scope(&self, scope: &AnnotationScope) -> Result<(), ContextError> {
384        match self {
385            Self::File { .. } => scope.validate(),
386            Self::State { .. } => {
387                if matches!(scope, AnnotationScope::File) {
388                    Ok(())
389                } else {
390                    Err(ContextError::StateTargetMustUseFileScope)
391                }
392            }
393        }
394    }
395
396    pub fn storage_path(&self) -> PathBuf {
397        match self {
398            Self::File { path } => Path::new(FILE_TARGET_ROOT).join(path),
399            Self::State { state_id } => {
400                Path::new(STATE_TARGET_ROOT).join(state_id.to_string_full())
401            }
402        }
403    }
404
405    pub fn from_storage_path(path: &Path) -> Option<Self> {
406        let mut components = path.components();
407        match components.next()? {
408            Component::Normal(part) if part == FILE_TARGET_ROOT => {
409                let rest = components.as_path();
410                if rest.as_os_str().is_empty() {
411                    None
412                } else {
413                    Some(Self::File {
414                        path: rest.to_string_lossy().to_string(),
415                    })
416                }
417            }
418            Component::Normal(part) if part == STATE_TARGET_ROOT => {
419                let rest = components.as_path();
420                let mut state_components = rest.components();
421                let Component::Normal(id) = state_components.next()? else {
422                    return None;
423                };
424                if !state_components.as_path().as_os_str().is_empty() {
425                    return None;
426                }
427                StateId::parse(&id.to_string_lossy())
428                    .ok()
429                    .map(|state_id| Self::State { state_id })
430            }
431            _ => None,
432        }
433    }
434
435    pub fn path(&self) -> Option<&str> {
436        match self {
437            Self::File { path } => Some(path),
438            Self::State { .. } => None,
439        }
440    }
441
442    pub fn state_id(&self) -> Option<StateId> {
443        match self {
444            Self::State { state_id } => Some(*state_id),
445            Self::File { .. } => None,
446        }
447    }
448}
449
450impl AnnotationScope {
451    pub fn validate(&self) -> Result<(), ContextError> {
452        match self {
453            Self::File => Ok(()),
454            Self::Symbol {
455                name,
456                resolved_lines,
457            } => {
458                if name.is_empty() {
459                    return Err(ContextError::EmptySymbol);
460                }
461                if let Some((start, end)) = resolved_lines
462                    && start > end
463                {
464                    return Err(ContextError::InvalidLineRange(*start, *end));
465                }
466                Ok(())
467            }
468            Self::Lines(start, end) => {
469                if start > end {
470                    Err(ContextError::InvalidLineRange(*start, *end))
471                } else {
472                    Ok(())
473                }
474            }
475        }
476    }
477
478    pub fn matches(&self, other: &Self) -> bool {
479        match (self, other) {
480            (Self::File, Self::File) => true,
481            (Self::Symbol { name: a, .. }, Self::Symbol { name: b, .. }) => a == b,
482            (Self::Lines(a1, a2), Self::Lines(b1, b2)) => a1 == b1 && a2 == b2,
483            _ => false,
484        }
485    }
486
487    pub fn symbol_name(&self) -> Option<&str> {
488        match self {
489            Self::Symbol { name, .. } => Some(name),
490            _ => None,
491        }
492    }
493
494    pub fn line_range(&self) -> Option<(u32, u32)> {
495        match self {
496            Self::Lines(start, end) => Some((*start, *end)),
497            Self::Symbol {
498                resolved_lines: Some((start, end)),
499                ..
500            } => Some((*start, *end)),
501            _ => None,
502        }
503    }
504}
505
506impl std::fmt::Display for AnnotationScope {
507    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
508        match self {
509            Self::File => write!(f, "file"),
510            Self::Symbol { name, .. } => write!(f, "symbol:{name}"),
511            Self::Lines(start, end) => write!(f, "lines:{start}-{end}"),
512        }
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    // --- ContextTarget::file validation --------------------------------
521
522    #[test]
523    fn context_target_accepts_relative_paths() {
524        // Plain relative, nested, and dotfile forms should all pass.
525        assert!(ContextTarget::file("src/auth.rs").is_ok());
526        assert!(ContextTarget::file("a/b/c.txt").is_ok());
527        assert!(ContextTarget::file(".gitignore").is_ok());
528        assert!(ContextTarget::file("a").is_ok());
529        // A leading `./` is pure noise; still accepted (the CurDir
530        // components are ignored, and `a` is a Normal component).
531        assert!(ContextTarget::file("./a").is_ok());
532    }
533
534    #[test]
535    fn context_target_rejects_empty_path() {
536        assert!(matches!(
537            ContextTarget::file(""),
538            Err(ContextError::EmptyTargetPath)
539        ));
540        assert!(matches!(
541            ContextTarget::file("   "),
542            Err(ContextError::EmptyTargetPath)
543        ));
544    }
545
546    #[test]
547    fn context_target_rejects_absolute_path_unix() {
548        let err = ContextTarget::file("/Users/me/repo/src/auth.rs").unwrap_err();
549        assert!(
550            matches!(err, ContextError::AbsoluteTargetPath(ref p) if p == "/Users/me/repo/src/auth.rs"),
551            "got {err:?}"
552        );
553        // Root alone also absolute.
554        assert!(matches!(
555            ContextTarget::file("/"),
556            Err(ContextError::AbsoluteTargetPath(_))
557        ));
558    }
559
560    #[test]
561    fn context_target_rejects_parent_escape() {
562        // `..` anywhere would let a writer escape `__files/` inside
563        // the context tree.
564        assert!(matches!(
565            ContextTarget::file("../etc/passwd"),
566            Err(ContextError::InvalidTargetPath(_))
567        ));
568        assert!(matches!(
569            ContextTarget::file("src/../../escape"),
570            Err(ContextError::InvalidTargetPath(_))
571        ));
572    }
573
574    #[test]
575    fn context_target_rejects_all_dot_components() {
576        // A path made entirely of `.`/`./.` is non-empty under the
577        // old check but has no Normal component to write under, so
578        // downstream writes would fail cryptically. Catch it here.
579        assert!(matches!(
580            ContextTarget::file("."),
581            Err(ContextError::InvalidTargetPath(_))
582        ));
583        assert!(matches!(
584            ContextTarget::file("./."),
585            Err(ContextError::InvalidTargetPath(_))
586        ));
587    }
588
589    #[test]
590    fn roundtrips_revision_with_missing_source_hash_and_present_state() {
591        let created_at_state = StateId::from_bytes([3; 32]);
592        let blob = ContextBlob::new(vec![Annotation::new(
593            AnnotationScope::File,
594            AnnotationKind::Rationale,
595            "Entry point".to_string(),
596            vec!["critical".to_string()],
597            "test@example.com".to_string(),
598            1700000000,
599            None,
600            Some(created_at_state),
601        )]);
602
603        let encoded = blob.encode().unwrap();
604        let decoded = ContextBlob::decode(&encoded).unwrap();
605        let revision = decoded.annotations[0].current_revision().unwrap();
606        assert_eq!(revision.source_hash, None);
607        assert_eq!(revision.created_at_state, Some(created_at_state));
608    }
609
610    #[test]
611    fn roundtrip_serialization() {
612        let blob = ContextBlob::new(vec![Annotation::new(
613            AnnotationScope::File,
614            AnnotationKind::Invariant,
615            "Entry point".to_string(),
616            vec!["constraint".to_string()],
617            "test@example.com".to_string(),
618            1700000000,
619            None,
620            None,
621        )]);
622
623        let bytes = blob.encode().unwrap();
624        let decoded = ContextBlob::decode(&bytes).unwrap();
625        assert_eq!(blob, decoded);
626    }
627
628    #[test]
629    fn legacy_annotation_without_anchor_status_decodes_as_resolved() {
630        #[derive(Serialize)]
631        struct LegacyAnnotation {
632            annotation_id: String,
633            scope: AnnotationScope,
634            status: AnnotationStatus,
635            revisions: Vec<AnnotationRevision>,
636            supersedes_annotation_id: Option<String>,
637            supersedes_rewrite_pct: Option<u32>,
638            visibility: VisibilityTier,
639            resolved_from_discussion: Option<String>,
640        }
641
642        #[derive(Serialize)]
643        struct LegacyContextBlob {
644            format_version: u8,
645            annotations: Vec<LegacyAnnotation>,
646        }
647
648        let revision = AnnotationRevision {
649            revision_id: "legacy-revision".to_string(),
650            kind: AnnotationKind::Invariant,
651            content: "legacy context".to_string(),
652            tags: vec![],
653            attribution: "test@example.com".to_string(),
654            created_at: 1_700_000_000,
655            source_hash: None,
656            created_at_state: None,
657        };
658        let bytes = rmp_serde::to_vec(&LegacyContextBlob {
659            format_version: ContextBlob::FORMAT_VERSION,
660            annotations: vec![LegacyAnnotation {
661                annotation_id: "legacy-annotation".to_string(),
662                scope: AnnotationScope::File,
663                status: AnnotationStatus::Active,
664                revisions: vec![revision],
665                supersedes_annotation_id: None,
666                supersedes_rewrite_pct: None,
667                visibility: VisibilityTier::default(),
668                resolved_from_discussion: None,
669            }],
670        })
671        .unwrap();
672
673        let decoded = ContextBlob::decode(&bytes).unwrap();
674        assert_eq!(
675            decoded.annotations[0].anchor_status,
676            AnnotationAnchorStatus::Resolved
677        );
678    }
679
680    #[test]
681    fn validate_good_blob() {
682        let blob = ContextBlob::new(vec![]);
683        blob.validate().unwrap();
684    }
685
686    #[test]
687    fn validate_bad_version() {
688        let blob = ContextBlob {
689            format_version: 99,
690            annotations: vec![],
691        };
692        assert!(matches!(
693            blob.validate(),
694            Err(ContextError::UnsupportedVersion(99))
695        ));
696    }
697
698    #[test]
699    fn validate_bad_line_range() {
700        let blob = ContextBlob::new(vec![Annotation::new(
701            AnnotationScope::Lines(20, 10),
702            AnnotationKind::Rationale,
703            "bad".to_string(),
704            vec![],
705            "test".to_string(),
706            0,
707            None,
708            None,
709        )]);
710        assert!(matches!(
711            blob.validate(),
712            Err(ContextError::InvalidLineRange(20, 10))
713        ));
714    }
715
716    #[test]
717    fn validate_empty_symbol() {
718        let blob = ContextBlob::new(vec![Annotation::new(
719            AnnotationScope::Symbol {
720                name: String::new(),
721                resolved_lines: None,
722            },
723            AnnotationKind::Rationale,
724            "bad".to_string(),
725            vec![],
726            "test".to_string(),
727            0,
728            None,
729            None,
730        )]);
731        assert!(matches!(blob.validate(), Err(ContextError::EmptySymbol)));
732    }
733
734    #[test]
735    fn scope_matching() {
736        assert!(AnnotationScope::File.matches(&AnnotationScope::File));
737        assert!(
738            AnnotationScope::Symbol {
739                name: "foo".into(),
740                resolved_lines: None
741            }
742            .matches(&AnnotationScope::Symbol {
743                name: "foo".into(),
744                resolved_lines: Some((1, 5))
745            })
746        );
747        assert!(
748            !AnnotationScope::Symbol {
749                name: "foo".into(),
750                resolved_lines: None
751            }
752            .matches(&AnnotationScope::Symbol {
753                name: "bar".into(),
754                resolved_lines: None
755            })
756        );
757        assert!(AnnotationScope::Lines(1, 10).matches(&AnnotationScope::Lines(1, 10)));
758    }
759
760    #[test]
761    fn state_targets_only_allow_file_scope() {
762        let target = ContextTarget::state(StateId::from_bytes([1; 32]));
763        assert!(target.validate_scope(&AnnotationScope::File).is_ok());
764        assert!(matches!(
765            target.validate_scope(&AnnotationScope::Lines(1, 2)),
766            Err(ContextError::StateTargetMustUseFileScope)
767        ));
768    }
769
770    #[test]
771    fn context_target_storage_roundtrip() {
772        let file = ContextTarget::file("src/main.rs").unwrap();
773        assert_eq!(
774            ContextTarget::from_storage_path(&file.storage_path()),
775            Some(file.clone())
776        );
777
778        let state = ContextTarget::state(StateId::from_bytes([2; 32]));
779        assert_eq!(
780            ContextTarget::from_storage_path(&state.storage_path()),
781            Some(state)
782        );
783    }
784
785    #[test]
786    fn context_target_storage_rejects_legacy_direct_paths() {
787        assert_eq!(
788            ContextTarget::from_storage_path(Path::new("src/main.rs")),
789            None
790        );
791    }
792}