Skip to main content

objects/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::{ChangeId, ContentHash},
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}
46
47/// A single revision of a logical annotation.
48#[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    /// BLAKE3 hash of the source bytes at the annotated scope when created.
57    /// For File scope: hash of entire file blob.
58    /// For Symbol/Lines: hash of the relevant byte range.
59    #[serde(default)]
60    pub source_hash: Option<ContentHash>,
61    /// The State this revision was created against.
62    /// Enables retrieving the exact source as it was at annotation time.
63    #[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/// The canonical annotation taxonomy the product surfaces.
74///
75/// `Constraint`, `Invariant`, and `Rationale` are the three kinds of
76/// reasoning we keep alongside code. The lowercase serde names are the
77/// wire/storage vocabulary shared with proto and the web API.
78#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum AnnotationKind {
81    /// A rule the code must obey. Example: "empty scope must return NoScope".
82    Constraint,
83    /// A property that must hold across operations. Example: "state DAG is append-only".
84    Invariant,
85    /// Design decision + reasoning. Example: "thread resolution walks to LCA because…".
86    Rationale,
87}
88
89/// A typed target for context entries.
90#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
91pub enum ContextTarget {
92    File { path: String },
93    State { change_id: ChangeId },
94}
95
96/// What part of a file an annotation targets.
97#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
98pub enum AnnotationScope {
99    File,
100    Symbol {
101        name: String,
102        /// Line range resolved at annotation creation time via tree-sitter.
103        /// Enables the web UI to show exact code for this symbol.
104        #[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
132// Current encoded format version is 2. Reject anything that isn't the
133// current value — no live deployments to migrate from.
134versioned_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    /// Construct a file-scope target. The path must be non-empty,
273    /// relative, and walkable — it's stored inside the context tree
274    /// under `__files/<path>`, and the downstream writer's
275    /// `split_path` helper only understands `Component::Normal` (no
276    /// `RootDir`, no `ParentDir`, no `CurDir`-only trails).
277    ///
278    /// Previously this accepted any non-empty string, which meant
279    /// absolute paths like `/Users/me/repo/src/auth.rs` got all the
280    /// way to `Repository::set_context_blob` before failing with a
281    /// cryptic `"empty path"` error deep in the tree-insert routine.
282    /// Rejecting here turns that into a clear
283    /// `AbsoluteTargetPath`/`InvalidTargetPath` at the callsite.
284    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        // Walk components: reject `..` anywhere (would let the path
294        // escape `__files/`), and require at least one `Normal`
295        // component (rejects paths like `.`, `./.`, or strings whose
296        // every component is `CurDir`).
297        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                    // `is_absolute` above already catches the typical
307                    // cases on both Unix and Windows, but belt-and-
308                    // braces: if a Prefix or RootDir sneaks through
309                    // on some platform, still reject.
310                    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    // --- ContextTarget::file validation --------------------------------
471
472    #[test]
473    fn context_target_accepts_relative_paths() {
474        // Plain relative, nested, and dotfile forms should all pass.
475        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        // A leading `./` is pure noise; still accepted (the CurDir
480        // components are ignored, and `a` is a Normal component).
481        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        // Root alone also absolute.
504        assert!(matches!(
505            ContextTarget::file("/"),
506            Err(ContextError::AbsoluteTargetPath(_))
507        ));
508    }
509
510    #[test]
511    fn context_target_rejects_parent_escape() {
512        // `..` anywhere would let a writer escape `__files/` inside
513        // the context tree.
514        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        // A path made entirely of `.`/`./.` is non-empty under the
527        // old check but has no Normal component to write under, so
528        // downstream writes would fail cryptically. Catch it here.
529        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}