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