Skip to main content

git_meta_lib/
types.rs

1use std::fmt;
2use std::str::FromStr;
3
4use sha1::{Digest, Sha1};
5
6use crate::error::{Error, Result};
7
8/// The kind of object a metadata entry is attached to.
9#[non_exhaustive]
10#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
11pub enum TargetType {
12    /// Metadata attached to a Git commit object.
13    Commit,
14    /// Metadata attached to a stable change identifier, such as a Jujutsu change ID.
15    ChangeId,
16    /// Metadata attached to a branch name.
17    Branch,
18    /// Metadata attached to a repository path.
19    Path,
20    /// Metadata attached to the whole project rather than a specific object.
21    Project,
22}
23
24impl fmt::Display for TargetType {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        f.write_str(self.as_str())
27    }
28}
29
30impl FromStr for TargetType {
31    type Err = Error;
32
33    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
34        match s {
35            "commit" => Ok(TargetType::Commit),
36            "change-id" => Ok(TargetType::ChangeId),
37            "branch" => Ok(TargetType::Branch),
38            "path" => Ok(TargetType::Path),
39            "project" => Ok(TargetType::Project),
40            _ => Err(Error::UnknownTargetType(s.to_string())),
41        }
42    }
43}
44
45impl TargetType {
46    /// Returns the wire-format string for this target type.
47    #[must_use]
48    pub fn as_str(&self) -> &str {
49        match self {
50            TargetType::Commit => "commit",
51            TargetType::ChangeId => "change-id",
52            TargetType::Branch => "branch",
53            TargetType::Path => "path",
54            TargetType::Project => "project",
55        }
56    }
57
58    /// Returns the English plural form of this target type for display.
59    #[must_use]
60    pub fn pluralize(&self) -> &str {
61        match self {
62            TargetType::Commit => "commits",
63            TargetType::ChangeId => "change-ids",
64            TargetType::Branch => "branches",
65            TargetType::Path => "paths",
66            TargetType::Project => "project",
67        }
68    }
69}
70
71/// A resolved metadata target consisting of a type and an optional value.
72#[derive(Debug, Clone, PartialEq, Eq, Hash)]
73#[must_use]
74pub struct Target {
75    target_type: TargetType,
76    value: Option<String>,
77}
78
79impl fmt::Display for Target {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match &self.value {
82            Some(v) => write!(f, "{}:{}", self.target_type, v),
83            None => write!(f, "{}", self.target_type),
84        }
85    }
86}
87
88impl Target {
89    /// Create a target from raw parts.
90    ///
91    /// This is a low-level constructor used when the target type and value are
92    /// already known (e.g., when reconstructing targets from database rows or
93    /// parsed tree entries). For user-facing construction, prefer the named
94    /// constructors ([`commit()`](Self::commit), [`project()`](Self::project), etc.)
95    /// or [`parse()`](Self::parse).
96    ///
97    /// # Parameters
98    /// - `target_type`: the kind of target
99    /// - `value`: the target value, or `None` for project targets
100    pub fn from_parts(target_type: TargetType, value: Option<String>) -> Self {
101        Target { target_type, value }
102    }
103
104    /// Create a commit target from a SHA (full or partial).
105    ///
106    /// # Parameters
107    /// - `sha`: a commit SHA string, must be at least 3 characters.
108    ///
109    /// # Errors
110    /// Returns an error if the SHA is shorter than 3 characters.
111    pub fn commit(sha: &str) -> Result<Self> {
112        Self::parse(&format!("commit:{sha}"))
113    }
114
115    /// Create a project-scoped target (no value needed).
116    pub fn project() -> Self {
117        Target {
118            target_type: TargetType::Project,
119            value: None,
120        }
121    }
122
123    /// Create a path target.
124    ///
125    /// # Parameters
126    /// - `path`: the file or directory path this metadata attaches to.
127    pub fn path(path: &str) -> Self {
128        Target {
129            target_type: TargetType::Path,
130            value: Some(path.to_string()),
131        }
132    }
133
134    /// Create a branch target.
135    ///
136    /// # Parameters
137    /// - `name`: the branch name this metadata attaches to.
138    pub fn branch(name: &str) -> Self {
139        Target {
140            target_type: TargetType::Branch,
141            value: Some(name.to_string()),
142        }
143    }
144
145    /// Create a change-id target.
146    ///
147    /// # Parameters
148    /// - `id`: the change identifier this metadata attaches to.
149    pub fn change_id(id: &str) -> Self {
150        Target {
151            target_type: TargetType::ChangeId,
152            value: Some(id.to_string()),
153        }
154    }
155
156    /// Parse a target from a string in `type:value` format (e.g. `"commit:abc123"`).
157    ///
158    /// This is the CLI-oriented constructor. For programmatic use, prefer the
159    /// named constructors: [`commit()`](Self::commit), [`project()`](Self::project),
160    /// [`path()`](Self::path), [`branch()`](Self::branch), [`change_id()`](Self::change_id).
161    ///
162    /// # Parameters
163    /// - `s`: the target string in `type:value` format, or `"project"` for project targets.
164    ///
165    /// # Errors
166    /// Returns an error if the format is invalid, the target type is unknown,
167    /// or the value is shorter than 3 characters.
168    pub fn parse(s: &str) -> Result<Self> {
169        if s == "project" {
170            return Ok(Target {
171                target_type: TargetType::Project,
172                value: None,
173            });
174        }
175
176        let (type_str, value) = s.split_once(':').ok_or_else(|| {
177            Error::InvalidTarget("target must be in type:value format (e.g. commit:abc123)".into())
178        })?;
179
180        let target_type = type_str.parse::<TargetType>()?;
181
182        if target_type == TargetType::Project {
183            return Ok(Target {
184                target_type,
185                value: None,
186            });
187        }
188
189        if value.len() < 3 {
190            return Err(Error::InvalidTarget(format!(
191                "target value must be at least 3 characters, got: {value}"
192            )));
193        }
194
195        Ok(Target {
196            target_type,
197            value: Some(value.to_string()),
198        })
199    }
200
201    /// The type of this target (commit, branch, path, etc.).
202    #[must_use]
203    pub fn target_type(&self) -> &TargetType {
204        &self.target_type
205    }
206
207    /// The target's value, if any.
208    ///
209    /// Returns `None` for project targets, `Some(sha)` for commit targets, etc.
210    #[must_use]
211    pub fn value(&self) -> Option<&str> {
212        self.value.as_deref()
213    }
214
215    /// If this is a commit target with a partial SHA, expand it to 40 chars
216    /// using the given Git repository. Returns a new target with the expanded SHA,
217    /// or a clone of this target if no resolution is needed.
218    pub fn resolve(&self, repo: &gix::Repository) -> Result<Target> {
219        if self.target_type == TargetType::Commit {
220            if let Some(ref v) = self.value {
221                if v.len() < 40 {
222                    let full = crate::git_utils::resolve_commit_sha(repo, v)?;
223                    return Ok(Target {
224                        target_type: self.target_type.clone(),
225                        value: Some(full),
226                    });
227                }
228            }
229        }
230        Ok(self.clone())
231    }
232}
233
234/// The storage type of a metadata value.
235#[non_exhaustive]
236#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
237pub enum ValueType {
238    /// A single string value; new writes replace the previous value.
239    String,
240    /// An ordered collection of timestamped entries; new writes append entries.
241    List,
242    /// An unordered collection of unique strings.
243    Set,
244}
245
246impl fmt::Display for ValueType {
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        f.write_str(self.as_str())
249    }
250}
251
252impl FromStr for ValueType {
253    type Err = Error;
254
255    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
256        match s {
257            "string" => Ok(ValueType::String),
258            "list" => Ok(ValueType::List),
259            "set" => Ok(ValueType::Set),
260            _ => Err(Error::UnknownValueType(s.to_string())),
261        }
262    }
263}
264
265impl ValueType {
266    /// Returns the wire-format string for this value type.
267    #[must_use]
268    pub fn as_str(&self) -> &str {
269        match self {
270            ValueType::String => "string",
271            ValueType::List => "list",
272            ValueType::Set => "set",
273        }
274    }
275}
276
277/// A metadata value with its type.
278///
279/// Combines value content with type information so they cannot get out of sync.
280/// Used as both input to [`Store::set()`](crate::db::Store::set) and output
281/// from [`Store::get()`](crate::db::Store::get).
282#[derive(Debug, Clone, PartialEq, Eq)]
283#[non_exhaustive]
284pub enum MetaValue {
285    /// A single string value.
286    String(String),
287    /// An ordered list of timestamped entries.
288    List(Vec<crate::ListEntry>),
289    /// An unordered set of unique string values.
290    Set(std::collections::BTreeSet<String>),
291}
292
293impl fmt::Display for MetaValue {
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        match self {
296            MetaValue::String(s) => write!(f, "{s}"),
297            MetaValue::List(entries) => write!(f, "[{} entries]", entries.len()),
298            MetaValue::Set(members) => write!(f, "{{{} members}}", members.len()),
299        }
300    }
301}
302
303impl MetaValue {
304    /// Returns the corresponding [`ValueType`].
305    #[must_use]
306    pub fn value_type(&self) -> ValueType {
307        match self {
308            MetaValue::String(_) => ValueType::String,
309            MetaValue::List(_) => ValueType::List,
310            MetaValue::Set(_) => ValueType::Set,
311        }
312    }
313}
314
315impl From<&str> for MetaValue {
316    fn from(s: &str) -> Self {
317        MetaValue::String(s.to_string())
318    }
319}
320
321impl From<String> for MetaValue {
322    fn from(s: String) -> Self {
323        MetaValue::String(s)
324    }
325}
326
327impl From<Vec<crate::ListEntry>> for MetaValue {
328    fn from(entries: Vec<crate::ListEntry>) -> Self {
329        MetaValue::List(entries)
330    }
331}
332
333impl From<std::collections::BTreeSet<String>> for MetaValue {
334    fn from(members: std::collections::BTreeSet<String>) -> Self {
335        MetaValue::Set(members)
336    }
337}
338
339/// A metadata edit that can be applied atomically with other edits.
340#[derive(Debug, Clone)]
341#[non_exhaustive]
342#[must_use]
343pub enum MetaEdit<'a> {
344    /// Append entries to a list value.
345    ListAppend {
346        /// The metadata key to append to.
347        key: &'a str,
348        /// Entries to append.
349        entries: &'a [crate::ListEntry],
350    },
351    /// Add members to a set value.
352    SetAdd {
353        /// The metadata key to add members to.
354        key: &'a str,
355        /// Members to add.
356        members: &'a [String],
357    },
358    /// Replace a metadata key with a typed value.
359    SetValue {
360        /// The metadata key to replace.
361        key: &'a str,
362        /// Value to store.
363        value: &'a crate::MetaValue,
364    },
365}
366
367impl<'a> MetaEdit<'a> {
368    /// Append entries to a list value.
369    ///
370    /// Entry timestamps preserve caller ordering. If an entry timestamp would
371    /// collide with or sort before an existing list item, GitMeta shifts it
372    /// forward to keep the appended entries at the end of the list.
373    pub fn list_append(key: &'a str, entries: &'a [crate::ListEntry]) -> Self {
374        Self::ListAppend { key, entries }
375    }
376
377    /// Add members to a set value.
378    pub fn set_add(key: &'a str, members: &'a [String]) -> Self {
379        Self::SetAdd { key, members }
380    }
381
382    /// Replace a metadata key with a typed value.
383    pub fn set_value(key: &'a str, value: &'a crate::MetaValue) -> Self {
384        Self::SetValue { key, value }
385    }
386}
387
388/// Size threshold (in bytes) above which file values are stored as git blob references.
389#[cfg(not(feature = "internal"))]
390pub(crate) const GIT_REF_THRESHOLD: usize = 1024;
391/// Size threshold (in bytes) above which file values are stored as git blob references.
392#[cfg(feature = "internal")]
393pub const GIT_REF_THRESHOLD: usize = 1024;
394
395/// Reserved filename for string terminal values.
396pub(crate) const STRING_VALUE_BLOB: &str = "__value";
397
398/// Reserved directory name for list terminal values.
399pub(crate) const LIST_VALUE_DIR: &str = "__list";
400
401/// Reserved directory name for set terminal values.
402pub(crate) const SET_VALUE_DIR: &str = "__set";
403
404/// Reserved directory for tombstone entries.
405pub(crate) const TOMBSTONE_ROOT: &str = "__tombstones";
406
407/// Reserved filename for tombstone blobs.
408pub(crate) const TOMBSTONE_BLOB: &str = "__deleted";
409
410/// Reserved separator between a serialized path target and its key path.
411pub(crate) const PATH_TARGET_SEPARATOR: &str = "__target__";
412
413/// Decode escaped path target segments back into a slash-separated path string.
414pub(crate) fn decode_path_target_segments(segments: &[&str]) -> Result<String> {
415    if segments.is_empty() {
416        return Err(Error::InvalidTreePath(
417            "path target must include at least one segment".into(),
418        ));
419    }
420
421    let decoded = segments
422        .iter()
423        .map(|segment| {
424            if let Some(rest) = segment.strip_prefix('~') {
425                rest.to_string()
426            } else {
427                (*segment).to_string()
428            }
429        })
430        .collect::<Vec<_>>()
431        .join("/");
432
433    Ok(decoded)
434}
435
436/// Compute a deterministic set member ID by hashing the value as a git blob.
437pub(crate) fn set_member_id(value: &str) -> String {
438    let header = format!("blob {}\0", value.len());
439    let mut hasher = Sha1::new();
440    hasher.update(header.as_bytes());
441    hasher.update(value.as_bytes());
442    format!("{:x}", hasher.finalize())
443}
444
445fn validate_key_segment(segment: &str) -> Result<()> {
446    if segment.is_empty() {
447        return Err(Error::InvalidKey("key segments cannot be empty".into()));
448    }
449    if segment == "." || segment == ".." {
450        return Err(Error::InvalidKey(format!(
451            "key segment '{segment}' is not allowed"
452        )));
453    }
454    if segment.contains('/') {
455        return Err(Error::InvalidKey(format!(
456            "key segment '{segment}' must not contain '/'"
457        )));
458    }
459    if segment.contains('\0') {
460        return Err(Error::InvalidKey(format!(
461            "key segment '{segment}' must not contain null byte"
462        )));
463    }
464    if segment.starts_with("__")
465        || segment == STRING_VALUE_BLOB
466        || segment == LIST_VALUE_DIR
467        || segment == SET_VALUE_DIR
468    {
469        return Err(Error::InvalidKey(format!(
470            "key segment '{segment}' is reserved"
471        )));
472    }
473    Ok(())
474}
475
476/// Validate that a metadata key can be serialized into the Git tree layout.
477///
478/// Called automatically by Store mutation methods. Library consumers do not
479/// need to call this directly unless validating keys before passing them to
480/// other systems.
481#[cfg(not(feature = "internal"))]
482pub(crate) fn validate_key(key: &str) -> Result<()> {
483    validate_key_inner(key)
484}
485
486/// Validate that a metadata key can be serialized into the Git tree layout.
487///
488/// Called automatically by Store mutation methods. Library consumers do not
489/// need to call this directly unless validating keys before passing them to
490/// other systems.
491#[cfg(feature = "internal")]
492pub fn validate_key(key: &str) -> Result<()> {
493    validate_key_inner(key)
494}
495
496fn validate_key_inner(key: &str) -> Result<()> {
497    if key.is_empty() {
498        return Err(Error::InvalidKey("key cannot be empty".into()));
499    }
500    for segment in key.split(':') {
501        validate_key_segment(segment)?;
502    }
503    Ok(())
504}
505
506/// Decode raw key path segments back into `:`-namespaced key form.
507pub(crate) fn decode_key_path_segments(segments: &[&str]) -> Result<String> {
508    if segments.is_empty() {
509        return Err(Error::InvalidKey(
510            "key path must include at least one key segment".into(),
511        ));
512    }
513    let mut decoded = Vec::with_capacity(segments.len());
514    for segment in segments {
515        validate_key_segment(segment)?;
516        decoded.push((*segment).to_string());
517    }
518    Ok(decoded.join(":"))
519}
520
521#[cfg(test)]
522#[allow(clippy::unwrap_used, clippy::expect_used)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn test_parse_commit_target() {
528        let t = Target::parse("commit:abc123").unwrap();
529        assert_eq!(t.target_type(), &TargetType::Commit);
530        assert_eq!(t.value(), Some("abc123"));
531    }
532
533    #[test]
534    fn test_parse_project_target() {
535        let t = Target::parse("project").unwrap();
536        assert_eq!(t.target_type(), &TargetType::Project);
537        assert_eq!(t.value(), None);
538    }
539
540    #[test]
541    fn test_parse_path_target_with_colon_in_value() {
542        // Only the first colon splits type from value
543        let t = Target::parse("path:src/foo.rs").unwrap();
544        assert_eq!(t.target_type(), &TargetType::Path);
545        assert_eq!(t.value(), Some("src/foo.rs"));
546    }
547
548    #[test]
549    fn test_parse_short_value_rejected() {
550        let result = Target::parse("commit:ab");
551        assert!(result.is_err());
552    }
553
554    #[test]
555    fn test_parse_unknown_type_rejected() {
556        let result = Target::parse("unknown:abc123");
557        assert!(result.is_err());
558    }
559
560    #[test]
561    fn test_value_type_roundtrip() {
562        assert_eq!("string".parse::<ValueType>().unwrap(), ValueType::String);
563        assert_eq!("list".parse::<ValueType>().unwrap(), ValueType::List);
564        assert_eq!("set".parse::<ValueType>().unwrap(), ValueType::Set);
565        assert!("hash".parse::<ValueType>().is_err());
566    }
567
568    #[test]
569    fn test_parse_branch_target() {
570        let t = Target::parse("branch:sc-branch-1-deadbeef").unwrap();
571        assert_eq!(t.target_type(), &TargetType::Branch);
572        assert_eq!(t.value(), Some("sc-branch-1-deadbeef"));
573    }
574
575    #[test]
576    fn test_decode_path_target_segments() {
577        let decoded =
578            super::decode_path_target_segments(&["src", "~__generated", "file.rs"]).unwrap();
579        assert_eq!(decoded, "src/__generated/file.rs");
580    }
581
582    #[test]
583    fn test_decode_key_path_segments() {
584        let decoded = super::decode_key_path_segments(&["agent", "model"]).unwrap();
585        assert_eq!(decoded, "agent:model");
586    }
587
588    #[test]
589    fn test_validate_key_rejects_reserved_segments() {
590        assert!(super::validate_key("agent:__value").is_err());
591        assert!(super::validate_key("__list:chat").is_err());
592        assert!(super::validate_key("__custom:model").is_err());
593    }
594
595    #[test]
596    fn test_validate_key_rejects_unsafe_segments() {
597        assert!(super::validate_key("agent:/model").is_err());
598        assert!(super::validate_key("agent::model").is_err());
599        assert!(super::validate_key("agent:.").is_err());
600        assert!(super::validate_key("agent:..").is_err());
601    }
602
603    #[test]
604    fn test_validate_key_accepts_normal_segments() {
605        assert!(super::validate_key("agent:model:version").is_ok());
606    }
607
608    #[test]
609    fn test_meta_value_string_type() {
610        let v = MetaValue::String("hello".to_string());
611        assert_eq!(v.value_type(), ValueType::String);
612    }
613
614    #[test]
615    fn test_meta_value_list_type() {
616        let v = MetaValue::List(vec![crate::list_value::ListEntry {
617            value: "item".to_string(),
618            timestamp: 1000,
619        }]);
620        assert_eq!(v.value_type(), ValueType::List);
621    }
622
623    #[test]
624    fn test_meta_value_set_type() {
625        let mut s = std::collections::BTreeSet::new();
626        s.insert("a".to_string());
627        s.insert("b".to_string());
628        let v = MetaValue::Set(s);
629        assert_eq!(v.value_type(), ValueType::Set);
630    }
631
632    #[test]
633    fn test_meta_value_empty_list_type() {
634        let v = MetaValue::List(vec![]);
635        assert_eq!(v.value_type(), ValueType::List);
636    }
637
638    #[test]
639    fn test_meta_value_empty_set_type() {
640        let v = MetaValue::Set(std::collections::BTreeSet::new());
641        assert_eq!(v.value_type(), ValueType::Set);
642    }
643
644    #[test]
645    fn test_meta_value_clone_eq() {
646        let v1 = MetaValue::String("test".to_string());
647        let v2 = v1.clone();
648        assert_eq!(v1, v2);
649    }
650
651    #[test]
652    fn test_target_commit_constructor() {
653        let t = Target::commit("abc123").unwrap();
654        assert_eq!(t.target_type(), &TargetType::Commit);
655        assert_eq!(t.value(), Some("abc123"));
656    }
657
658    #[test]
659    fn test_target_commit_constructor_short_sha_rejected() {
660        let result = Target::commit("ab");
661        assert!(result.is_err());
662    }
663
664    #[test]
665    fn test_target_project_constructor() {
666        let t = Target::project();
667        assert_eq!(t.target_type(), &TargetType::Project);
668        assert_eq!(t.value(), None);
669    }
670
671    #[test]
672    fn test_target_path_constructor() {
673        let t = Target::path("src/main.rs");
674        assert_eq!(t.target_type(), &TargetType::Path);
675        assert_eq!(t.value(), Some("src/main.rs"));
676    }
677
678    #[test]
679    fn test_target_branch_constructor() {
680        let t = Target::branch("feature-x");
681        assert_eq!(t.target_type(), &TargetType::Branch);
682        assert_eq!(t.value(), Some("feature-x"));
683    }
684
685    #[test]
686    fn test_target_change_id_constructor() {
687        let t = Target::change_id("jj-change-abc");
688        assert_eq!(t.target_type(), &TargetType::ChangeId);
689        assert_eq!(t.value(), Some("jj-change-abc"));
690    }
691
692    #[test]
693    fn test_named_constructors_match_parse() {
694        // Verify named constructors produce identical results to parse
695        let from_parse = Target::parse("commit:abc123").unwrap();
696        let from_ctor = Target::commit("abc123").unwrap();
697        assert_eq!(from_parse, from_ctor);
698
699        let from_parse = Target::parse("project").unwrap();
700        let from_ctor = Target::project();
701        assert_eq!(from_parse, from_ctor);
702
703        let from_parse = Target::parse("path:src/main.rs").unwrap();
704        let from_ctor = Target::path("src/main.rs");
705        assert_eq!(from_parse, from_ctor);
706
707        let from_parse = Target::parse("branch:feature-x").unwrap();
708        let from_ctor = Target::branch("feature-x");
709        assert_eq!(from_parse, from_ctor);
710
711        let from_parse = Target::parse("change-id:jj-change-abc").unwrap();
712        let from_ctor = Target::change_id("jj-change-abc");
713        assert_eq!(from_parse, from_ctor);
714    }
715}