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