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::hash::{ChangeId, ContentHash};
9
10const FILE_TARGET_ROOT: &str = "__files";
11const STATE_TARGET_ROOT: &str = "__states";
12
13/// A collection of logical annotations for a single target.
14#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
15pub struct ContextBlob {
16    pub format_version: u8,
17    pub annotations: Vec<Annotation>,
18}
19
20/// A stable logical annotation with revision history.
21#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
22pub struct Annotation {
23    pub annotation_id: String,
24    pub scope: AnnotationScope,
25    pub status: AnnotationStatus,
26    pub revisions: Vec<AnnotationRevision>,
27    #[serde(default)]
28    pub supersedes_annotation_id: Option<String>,
29    #[serde(default)]
30    pub supersedes_rewrite_pct: Option<u32>,
31    // --- tail-only optional fields below; new fields go here. ---
32    /// Visibility scope. Pre-W1 annotations have no field on disk; rmp-serde
33    /// fills the default ([`AnnotationVisibility::Public`]), preserving the
34    /// pre-existing meaning ("annotations are publicly visible").
35    #[serde(default)]
36    pub visibility: AnnotationVisibility,
37    /// Back-pointer set when this annotation was produced by resolving a
38    /// discussion. Lets viewers jump from the annotation back to the
39    /// discussion that produced it.
40    #[serde(default)]
41    pub resolved_from_discussion: Option<String>,
42}
43
44/// Visibility scope for an annotation. Determines which audiences see it on
45/// read paths and during bridge export.
46///
47/// `Public` is the default — that matches pre-W1 behavior, where every
48/// annotation was effectively public, so legacy data decodes unchanged.
49/// External references (annotations that point to external systems) inherit
50/// the scope of their parent annotation.
51#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
52pub enum AnnotationVisibility {
53    #[default]
54    Public,
55    Internal,
56    TeamScoped {
57        team_id: String,
58    },
59    Restricted {
60        scope_label: String,
61    },
62}
63
64impl AnnotationVisibility {
65    pub fn as_str(&self) -> &'static str {
66        match self {
67            Self::Public => "public",
68            Self::Internal => "internal",
69            Self::TeamScoped { .. } => "team_scoped",
70            Self::Restricted { .. } => "restricted",
71        }
72    }
73}
74
75/// A single revision of a logical annotation.
76#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
77pub struct AnnotationRevision {
78    pub revision_id: String,
79    pub kind: AnnotationKind,
80    pub content: String,
81    pub tags: Vec<String>,
82    pub attribution: String,
83    pub created_at: i64,
84    /// BLAKE3 hash of the source bytes at the annotated scope when created.
85    /// For File scope: hash of entire file blob.
86    /// For Symbol/Lines: hash of the relevant byte range.
87    #[serde(default)]
88    pub source_hash: Option<ContentHash>,
89    /// The State this revision was created against.
90    /// Enables retrieving the exact source as it was at annotation time.
91    #[serde(default)]
92    pub created_at_state: Option<ChangeId>,
93}
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
96pub enum AnnotationStatus {
97    Active,
98    Superseded,
99}
100
101/// The canonical annotation taxonomy the product surfaces.
102///
103/// `Constraint`, `Invariant`, and `Rationale` are the three kinds of
104/// reasoning we keep alongside code. The lowercase serde names are the
105/// wire/storage vocabulary shared with proto and the web API.
106#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "lowercase")]
108pub enum AnnotationKind {
109    /// A rule the code must obey. Example: "empty scope must return NoScope".
110    Constraint,
111    /// A property that must hold across operations. Example: "state DAG is append-only".
112    Invariant,
113    /// Design decision + reasoning. Example: "thread resolution walks to LCA because…".
114    Rationale,
115}
116
117/// A typed target for context entries.
118#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
119pub enum ContextTarget {
120    File { path: String },
121    State { change_id: ChangeId },
122}
123
124/// What part of a file an annotation targets.
125#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
126pub enum AnnotationScope {
127    File,
128    Symbol {
129        name: String,
130        /// Line range resolved at annotation creation time via tree-sitter.
131        /// Enables the web UI to show exact code for this symbol.
132        #[serde(default, skip_serializing_if = "Option::is_none")]
133        resolved_lines: Option<(u32, u32)>,
134    },
135    Lines(u32, u32),
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
139pub enum ContextError {
140    #[error("unsupported context format version {0}")]
141    UnsupportedVersion(u8),
142    #[error("line range start {0} exceeds end {1}")]
143    InvalidLineRange(u32, u32),
144    #[error("symbol name must not be empty")]
145    EmptySymbol,
146    #[error("file target path must not be empty")]
147    EmptyTargetPath,
148    #[error("context target path must be relative, got: {0}")]
149    AbsoluteTargetPath(String),
150    #[error("invalid context target path: {0}")]
151    InvalidTargetPath(String),
152    #[error("state-level guidance must use file scope only")]
153    StateTargetMustUseFileScope,
154    #[error("annotation {0} has no revisions")]
155    MissingRevisions(String),
156    #[error("invalid context encoding: {0}")]
157    InvalidEncoding(String),
158}
159
160impl ContextBlob {
161    /// Current encoded format version. Reject anything that isn't the
162    /// current value — no live deployments to migrate from.
163    pub const FORMAT_VERSION: u8 = 2;
164
165    pub fn new(annotations: Vec<Annotation>) -> Self {
166        Self {
167            format_version: Self::FORMAT_VERSION,
168            annotations,
169        }
170    }
171
172    pub fn validate(&self) -> Result<(), ContextError> {
173        if self.format_version != Self::FORMAT_VERSION {
174            return Err(ContextError::UnsupportedVersion(self.format_version));
175        }
176        for annotation in &self.annotations {
177            annotation.validate()?;
178        }
179        Ok(())
180    }
181
182    pub fn encode(&self) -> Result<Vec<u8>, ContextError> {
183        rmp_serde::to_vec(self).map_err(|err| ContextError::InvalidEncoding(err.to_string()))
184    }
185
186    pub fn decode(bytes: &[u8]) -> Result<Self, ContextError> {
187        let blob: Self = rmp_serde::from_slice(bytes)
188            .map_err(|err| ContextError::InvalidEncoding(err.to_string()))?;
189        blob.validate()?;
190        Ok(blob)
191    }
192}
193
194impl Annotation {
195    #[allow(clippy::too_many_arguments)]
196    pub fn new(
197        scope: AnnotationScope,
198        kind: AnnotationKind,
199        content: String,
200        tags: Vec<String>,
201        attribution: String,
202        created_at: i64,
203        source_hash: Option<ContentHash>,
204        created_at_state: Option<ChangeId>,
205    ) -> Self {
206        Self {
207            annotation_id: ChangeId::generate().to_string_full(),
208            scope,
209            status: AnnotationStatus::Active,
210            revisions: vec![AnnotationRevision {
211                revision_id: ChangeId::generate().to_string_full(),
212                kind,
213                content,
214                tags,
215                attribution,
216                created_at,
217                source_hash,
218                created_at_state,
219            }],
220            supersedes_annotation_id: None,
221            supersedes_rewrite_pct: None,
222            visibility: AnnotationVisibility::default(),
223            resolved_from_discussion: None,
224        }
225    }
226
227    pub fn current_revision(&self) -> Option<&AnnotationRevision> {
228        self.revisions.last()
229    }
230
231    pub fn current_revision_mut(&mut self) -> Option<&mut AnnotationRevision> {
232        self.revisions.last_mut()
233    }
234
235    #[allow(clippy::too_many_arguments)]
236    pub fn revise(
237        &mut self,
238        kind: AnnotationKind,
239        content: String,
240        tags: Vec<String>,
241        attribution: String,
242        created_at: i64,
243        source_hash: Option<ContentHash>,
244        created_at_state: Option<ChangeId>,
245    ) -> &AnnotationRevision {
246        self.revisions.push(AnnotationRevision {
247            revision_id: ChangeId::generate().to_string_full(),
248            kind,
249            content,
250            tags,
251            attribution,
252            created_at,
253            source_hash,
254            created_at_state,
255        });
256        self.current_revision().expect("new revision appended")
257    }
258
259    pub fn mark_superseded(&mut self) {
260        self.status = AnnotationStatus::Superseded;
261    }
262
263    pub fn validate(&self) -> Result<(), ContextError> {
264        self.scope.validate()?;
265        if self.annotation_id.is_empty() {
266            return Err(ContextError::InvalidEncoding(
267                "annotation_id must not be empty".to_string(),
268            ));
269        }
270        if self.revisions.is_empty() {
271            return Err(ContextError::MissingRevisions(self.annotation_id.clone()));
272        }
273        for revision in &self.revisions {
274            revision.validate()?;
275        }
276        Ok(())
277    }
278}
279
280impl AnnotationRevision {
281    pub fn validate(&self) -> Result<(), ContextError> {
282        if self.revision_id.is_empty() {
283            return Err(ContextError::InvalidEncoding(
284                "revision_id must not be empty".to_string(),
285            ));
286        }
287        Ok(())
288    }
289}
290
291impl AnnotationKind {
292    pub fn as_str(&self) -> &'static str {
293        match self {
294            Self::Constraint => "constraint",
295            Self::Invariant => "invariant",
296            Self::Rationale => "rationale",
297        }
298    }
299}
300
301impl std::fmt::Display for AnnotationKind {
302    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303        write!(f, "{}", self.as_str())
304    }
305}
306
307impl std::str::FromStr for AnnotationKind {
308    type Err = ContextError;
309
310    fn from_str(value: &str) -> Result<Self, Self::Err> {
311        match value {
312            "constraint" => Ok(Self::Constraint),
313            "invariant" => Ok(Self::Invariant),
314            "rationale" => Ok(Self::Rationale),
315            _ => Err(ContextError::InvalidEncoding(format!(
316                "invalid annotation kind '{value}'"
317            ))),
318        }
319    }
320}
321
322impl ContextTarget {
323    /// Construct a file-scope target. The path must be non-empty,
324    /// relative, and walkable — it's stored inside the context tree
325    /// under `__files/<path>`, and the downstream writer's
326    /// `split_path` helper only understands `Component::Normal` (no
327    /// `RootDir`, no `ParentDir`, no `CurDir`-only trails).
328    ///
329    /// Previously this accepted any non-empty string, which meant
330    /// absolute paths like `/Users/me/repo/src/auth.rs` got all the
331    /// way to `Repository::set_context_blob` before failing with a
332    /// cryptic `"empty path"` error deep in the tree-insert routine.
333    /// Rejecting here turns that into a clear
334    /// `AbsoluteTargetPath`/`InvalidTargetPath` at the callsite.
335    pub fn file(path: impl Into<String>) -> Result<Self, ContextError> {
336        let path = path.into();
337        if path.trim().is_empty() {
338            return Err(ContextError::EmptyTargetPath);
339        }
340        let p = Path::new(&path);
341        if p.is_absolute() {
342            return Err(ContextError::AbsoluteTargetPath(path));
343        }
344        // Walk components: reject `..` anywhere (would let the path
345        // escape `__files/`), and require at least one `Normal`
346        // component (rejects paths like `.`, `./.`, or strings whose
347        // every component is `CurDir`).
348        let mut saw_normal = false;
349        for component in p.components() {
350            match component {
351                Component::Normal(_) => saw_normal = true,
352                Component::CurDir => {}
353                Component::ParentDir => {
354                    return Err(ContextError::InvalidTargetPath(path));
355                }
356                Component::RootDir | Component::Prefix(_) => {
357                    // `is_absolute` above already catches the typical
358                    // cases on both Unix and Windows, but belt-and-
359                    // braces: if a Prefix or RootDir sneaks through
360                    // on some platform, still reject.
361                    return Err(ContextError::AbsoluteTargetPath(path));
362                }
363            }
364        }
365        if !saw_normal {
366            return Err(ContextError::InvalidTargetPath(path));
367        }
368        Ok(Self::File { path })
369    }
370
371    pub fn state(change_id: ChangeId) -> Self {
372        Self::State { change_id }
373    }
374
375    pub fn validate_scope(&self, scope: &AnnotationScope) -> Result<(), ContextError> {
376        match self {
377            Self::File { .. } => scope.validate(),
378            Self::State { .. } => {
379                if matches!(scope, AnnotationScope::File) {
380                    Ok(())
381                } else {
382                    Err(ContextError::StateTargetMustUseFileScope)
383                }
384            }
385        }
386    }
387
388    pub fn storage_path(&self) -> PathBuf {
389        match self {
390            Self::File { path } => Path::new(FILE_TARGET_ROOT).join(path),
391            Self::State { change_id } => {
392                Path::new(STATE_TARGET_ROOT).join(change_id.to_string_full())
393            }
394        }
395    }
396
397    pub fn legacy_storage_path(&self) -> Option<PathBuf> {
398        match self {
399            Self::File { path } => Some(PathBuf::from(path)),
400            Self::State { .. } => None,
401        }
402    }
403
404    pub fn from_storage_path(path: &Path) -> Option<Self> {
405        let mut components = path.components();
406        match components.next()? {
407            Component::Normal(part) if part == FILE_TARGET_ROOT => {
408                let rest = components.as_path();
409                if rest.as_os_str().is_empty() {
410                    None
411                } else {
412                    Some(Self::File {
413                        path: rest.to_string_lossy().to_string(),
414                    })
415                }
416            }
417            Component::Normal(part) if part == STATE_TARGET_ROOT => {
418                let rest = components.as_path();
419                let mut state_components = rest.components();
420                let Component::Normal(id) = state_components.next()? else {
421                    return None;
422                };
423                if !state_components.as_path().as_os_str().is_empty() {
424                    return None;
425                }
426                ChangeId::parse(&id.to_string_lossy())
427                    .ok()
428                    .map(|change_id| Self::State { change_id })
429            }
430            _ => Some(Self::File {
431                path: path.to_string_lossy().to_string(),
432            }),
433        }
434    }
435
436    pub fn path(&self) -> Option<&str> {
437        match self {
438            Self::File { path } => Some(path),
439            Self::State { .. } => None,
440        }
441    }
442
443    pub fn state_id(&self) -> Option<ChangeId> {
444        match self {
445            Self::State { change_id } => Some(*change_id),
446            Self::File { .. } => None,
447        }
448    }
449}
450
451impl AnnotationScope {
452    pub fn validate(&self) -> Result<(), ContextError> {
453        match self {
454            Self::File => Ok(()),
455            Self::Symbol {
456                name,
457                resolved_lines,
458            } => {
459                if name.is_empty() {
460                    return Err(ContextError::EmptySymbol);
461                }
462                if let Some((start, end)) = resolved_lines
463                    && start > end
464                {
465                    return Err(ContextError::InvalidLineRange(*start, *end));
466                }
467                Ok(())
468            }
469            Self::Lines(start, end) => {
470                if start > end {
471                    Err(ContextError::InvalidLineRange(*start, *end))
472                } else {
473                    Ok(())
474                }
475            }
476        }
477    }
478
479    pub fn matches(&self, other: &Self) -> bool {
480        match (self, other) {
481            (Self::File, Self::File) => true,
482            (Self::Symbol { name: a, .. }, Self::Symbol { name: b, .. }) => a == b,
483            (Self::Lines(a1, a2), Self::Lines(b1, b2)) => a1 == b1 && a2 == b2,
484            _ => false,
485        }
486    }
487
488    pub fn symbol_name(&self) -> Option<&str> {
489        match self {
490            Self::Symbol { name, .. } => Some(name),
491            _ => None,
492        }
493    }
494
495    pub fn line_range(&self) -> Option<(u32, u32)> {
496        match self {
497            Self::Lines(start, end) => Some((*start, *end)),
498            Self::Symbol {
499                resolved_lines: Some((start, end)),
500                ..
501            } => Some((*start, *end)),
502            _ => None,
503        }
504    }
505}
506
507impl std::fmt::Display for AnnotationScope {
508    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509        match self {
510            Self::File => write!(f, "file"),
511            Self::Symbol { name, .. } => write!(f, "symbol:{name}"),
512            Self::Lines(start, end) => write!(f, "lines:{start}-{end}"),
513        }
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    // --- ContextTarget::file validation --------------------------------
522
523    #[test]
524    fn context_target_accepts_relative_paths() {
525        // Plain relative, nested, and dotfile forms should all pass.
526        assert!(ContextTarget::file("src/auth.rs").is_ok());
527        assert!(ContextTarget::file("a/b/c.txt").is_ok());
528        assert!(ContextTarget::file(".gitignore").is_ok());
529        assert!(ContextTarget::file("a").is_ok());
530        // A leading `./` is pure noise; still accepted (the CurDir
531        // components are ignored, and `a` is a Normal component).
532        assert!(ContextTarget::file("./a").is_ok());
533    }
534
535    #[test]
536    fn context_target_rejects_empty_path() {
537        assert!(matches!(
538            ContextTarget::file(""),
539            Err(ContextError::EmptyTargetPath)
540        ));
541        assert!(matches!(
542            ContextTarget::file("   "),
543            Err(ContextError::EmptyTargetPath)
544        ));
545    }
546
547    #[test]
548    fn context_target_rejects_absolute_path_unix() {
549        let err = ContextTarget::file("/Users/me/repo/src/auth.rs").unwrap_err();
550        assert!(
551            matches!(err, ContextError::AbsoluteTargetPath(ref p) if p == "/Users/me/repo/src/auth.rs"),
552            "got {err:?}"
553        );
554        // Root alone also absolute.
555        assert!(matches!(
556            ContextTarget::file("/"),
557            Err(ContextError::AbsoluteTargetPath(_))
558        ));
559    }
560
561    #[test]
562    fn context_target_rejects_parent_escape() {
563        // `..` anywhere would let a writer escape `__files/` inside
564        // the context tree.
565        assert!(matches!(
566            ContextTarget::file("../etc/passwd"),
567            Err(ContextError::InvalidTargetPath(_))
568        ));
569        assert!(matches!(
570            ContextTarget::file("src/../../escape"),
571            Err(ContextError::InvalidTargetPath(_))
572        ));
573    }
574
575    #[test]
576    fn context_target_rejects_all_dot_components() {
577        // A path made entirely of `.`/`./.` is non-empty under the
578        // old check but has no Normal component to write under, so
579        // downstream writes would fail cryptically. Catch it here.
580        assert!(matches!(
581            ContextTarget::file("."),
582            Err(ContextError::InvalidTargetPath(_))
583        ));
584        assert!(matches!(
585            ContextTarget::file("./."),
586            Err(ContextError::InvalidTargetPath(_))
587        ));
588    }
589
590    #[test]
591    fn roundtrips_revision_with_missing_source_hash_and_present_state() {
592        let created_at_state = ChangeId::generate();
593        let blob = ContextBlob::new(vec![Annotation::new(
594            AnnotationScope::File,
595            AnnotationKind::Rationale,
596            "Entry point".to_string(),
597            vec!["critical".to_string()],
598            "test@example.com".to_string(),
599            1700000000,
600            None,
601            Some(created_at_state),
602        )]);
603
604        let encoded = blob.encode().unwrap();
605        let decoded = ContextBlob::decode(&encoded).unwrap();
606        let revision = decoded.annotations[0].current_revision().unwrap();
607        assert_eq!(revision.source_hash, None);
608        assert_eq!(revision.created_at_state, Some(created_at_state));
609    }
610
611    #[test]
612    fn roundtrip_serialization() {
613        let blob = ContextBlob::new(vec![Annotation::new(
614            AnnotationScope::File,
615            AnnotationKind::Invariant,
616            "Entry point".to_string(),
617            vec!["constraint".to_string()],
618            "test@example.com".to_string(),
619            1700000000,
620            None,
621            None,
622        )]);
623
624        let bytes = blob.encode().unwrap();
625        let decoded = ContextBlob::decode(&bytes).unwrap();
626        assert_eq!(blob, decoded);
627    }
628
629    #[test]
630    fn validate_good_blob() {
631        let blob = ContextBlob::new(vec![]);
632        blob.validate().unwrap();
633    }
634
635    #[test]
636    fn validate_bad_version() {
637        let blob = ContextBlob {
638            format_version: 99,
639            annotations: vec![],
640        };
641        assert!(matches!(
642            blob.validate(),
643            Err(ContextError::UnsupportedVersion(99))
644        ));
645    }
646
647    #[test]
648    fn validate_bad_line_range() {
649        let blob = ContextBlob::new(vec![Annotation::new(
650            AnnotationScope::Lines(20, 10),
651            AnnotationKind::Rationale,
652            "bad".to_string(),
653            vec![],
654            "test".to_string(),
655            0,
656            None,
657            None,
658        )]);
659        assert!(matches!(
660            blob.validate(),
661            Err(ContextError::InvalidLineRange(20, 10))
662        ));
663    }
664
665    #[test]
666    fn validate_empty_symbol() {
667        let blob = ContextBlob::new(vec![Annotation::new(
668            AnnotationScope::Symbol {
669                name: String::new(),
670                resolved_lines: None,
671            },
672            AnnotationKind::Rationale,
673            "bad".to_string(),
674            vec![],
675            "test".to_string(),
676            0,
677            None,
678            None,
679        )]);
680        assert!(matches!(blob.validate(), Err(ContextError::EmptySymbol)));
681    }
682
683    #[test]
684    fn scope_matching() {
685        assert!(AnnotationScope::File.matches(&AnnotationScope::File));
686        assert!(
687            AnnotationScope::Symbol {
688                name: "foo".into(),
689                resolved_lines: None
690            }
691            .matches(&AnnotationScope::Symbol {
692                name: "foo".into(),
693                resolved_lines: Some((1, 5))
694            })
695        );
696        assert!(
697            !AnnotationScope::Symbol {
698                name: "foo".into(),
699                resolved_lines: None
700            }
701            .matches(&AnnotationScope::Symbol {
702                name: "bar".into(),
703                resolved_lines: None
704            })
705        );
706        assert!(AnnotationScope::Lines(1, 10).matches(&AnnotationScope::Lines(1, 10)));
707    }
708
709    #[test]
710    fn state_targets_only_allow_file_scope() {
711        let target = ContextTarget::state(ChangeId::generate());
712        assert!(target.validate_scope(&AnnotationScope::File).is_ok());
713        assert!(matches!(
714            target.validate_scope(&AnnotationScope::Lines(1, 2)),
715            Err(ContextError::StateTargetMustUseFileScope)
716        ));
717    }
718
719    #[test]
720    fn context_target_storage_roundtrip() {
721        let file = ContextTarget::file("src/main.rs").unwrap();
722        assert_eq!(
723            ContextTarget::from_storage_path(&file.storage_path()),
724            Some(file.clone())
725        );
726
727        let state = ContextTarget::state(ChangeId::generate());
728        assert_eq!(
729            ContextTarget::from_storage_path(&state.storage_path()),
730            Some(state)
731        );
732    }
733}