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::list_value::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::list_value::ListEntry>> for MetaValue {
321    fn from(entries: Vec<crate::list_value::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/// Size threshold (in bytes) above which file values are stored as git blob references.
333#[cfg(not(feature = "internal"))]
334pub(crate) const GIT_REF_THRESHOLD: usize = 1024;
335/// Size threshold (in bytes) above which file values are stored as git blob references.
336#[cfg(feature = "internal")]
337pub const GIT_REF_THRESHOLD: usize = 1024;
338
339/// Reserved filename for string terminal values.
340pub(crate) const STRING_VALUE_BLOB: &str = "__value";
341
342/// Reserved directory name for list terminal values.
343pub(crate) const LIST_VALUE_DIR: &str = "__list";
344
345/// Reserved directory name for set terminal values.
346pub(crate) const SET_VALUE_DIR: &str = "__set";
347
348/// Reserved directory for tombstone entries.
349pub(crate) const TOMBSTONE_ROOT: &str = "__tombstones";
350
351/// Reserved filename for tombstone blobs.
352pub(crate) const TOMBSTONE_BLOB: &str = "__deleted";
353
354/// Reserved separator between a serialized path target and its key path.
355pub(crate) const PATH_TARGET_SEPARATOR: &str = "__target__";
356
357/// Decode escaped path target segments back into a slash-separated path string.
358pub(crate) fn decode_path_target_segments(segments: &[&str]) -> Result<String> {
359    if segments.is_empty() {
360        return Err(Error::InvalidTreePath(
361            "path target must include at least one segment".into(),
362        ));
363    }
364
365    let decoded = segments
366        .iter()
367        .map(|segment| {
368            if let Some(rest) = segment.strip_prefix('~') {
369                rest.to_string()
370            } else {
371                (*segment).to_string()
372            }
373        })
374        .collect::<Vec<_>>()
375        .join("/");
376
377    Ok(decoded)
378}
379
380/// Compute a deterministic set member ID by hashing the value as a git blob.
381pub(crate) fn set_member_id(value: &str) -> String {
382    let header = format!("blob {}\0", value.len());
383    let mut hasher = Sha1::new();
384    hasher.update(header.as_bytes());
385    hasher.update(value.as_bytes());
386    format!("{:x}", hasher.finalize())
387}
388
389fn validate_key_segment(segment: &str) -> Result<()> {
390    if segment.is_empty() {
391        return Err(Error::InvalidKey("key segments cannot be empty".into()));
392    }
393    if segment == "." || segment == ".." {
394        return Err(Error::InvalidKey(format!(
395            "key segment '{segment}' is not allowed"
396        )));
397    }
398    if segment.contains('/') {
399        return Err(Error::InvalidKey(format!(
400            "key segment '{segment}' must not contain '/'"
401        )));
402    }
403    if segment.contains('\0') {
404        return Err(Error::InvalidKey(format!(
405            "key segment '{segment}' must not contain null byte"
406        )));
407    }
408    if segment.starts_with("__")
409        || segment == STRING_VALUE_BLOB
410        || segment == LIST_VALUE_DIR
411        || segment == SET_VALUE_DIR
412    {
413        return Err(Error::InvalidKey(format!(
414            "key segment '{segment}' is reserved"
415        )));
416    }
417    Ok(())
418}
419
420/// Validate that a metadata key can be serialized into the Git tree layout.
421///
422/// Called automatically by Store mutation methods. Library consumers do not
423/// need to call this directly unless validating keys before passing them to
424/// other systems.
425#[cfg(not(feature = "internal"))]
426pub(crate) fn validate_key(key: &str) -> Result<()> {
427    validate_key_inner(key)
428}
429
430/// Validate that a metadata key can be serialized into the Git tree layout.
431///
432/// Called automatically by Store mutation methods. Library consumers do not
433/// need to call this directly unless validating keys before passing them to
434/// other systems.
435#[cfg(feature = "internal")]
436pub fn validate_key(key: &str) -> Result<()> {
437    validate_key_inner(key)
438}
439
440fn validate_key_inner(key: &str) -> Result<()> {
441    if key.is_empty() {
442        return Err(Error::InvalidKey("key cannot be empty".into()));
443    }
444    for segment in key.split(':') {
445        validate_key_segment(segment)?;
446    }
447    Ok(())
448}
449
450/// Decode raw key path segments back into `:`-namespaced key form.
451pub(crate) fn decode_key_path_segments(segments: &[&str]) -> Result<String> {
452    if segments.is_empty() {
453        return Err(Error::InvalidKey(
454            "key path must include at least one key segment".into(),
455        ));
456    }
457    let mut decoded = Vec::with_capacity(segments.len());
458    for segment in segments {
459        validate_key_segment(segment)?;
460        decoded.push((*segment).to_string());
461    }
462    Ok(decoded.join(":"))
463}
464
465#[cfg(test)]
466#[allow(clippy::unwrap_used, clippy::expect_used)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn test_parse_commit_target() {
472        let t = Target::parse("commit:abc123").unwrap();
473        assert_eq!(t.target_type(), &TargetType::Commit);
474        assert_eq!(t.value(), Some("abc123"));
475    }
476
477    #[test]
478    fn test_parse_project_target() {
479        let t = Target::parse("project").unwrap();
480        assert_eq!(t.target_type(), &TargetType::Project);
481        assert_eq!(t.value(), None);
482    }
483
484    #[test]
485    fn test_parse_path_target_with_colon_in_value() {
486        // Only the first colon splits type from value
487        let t = Target::parse("path:src/foo.rs").unwrap();
488        assert_eq!(t.target_type(), &TargetType::Path);
489        assert_eq!(t.value(), Some("src/foo.rs"));
490    }
491
492    #[test]
493    fn test_parse_short_value_rejected() {
494        let result = Target::parse("commit:ab");
495        assert!(result.is_err());
496    }
497
498    #[test]
499    fn test_parse_unknown_type_rejected() {
500        let result = Target::parse("unknown:abc123");
501        assert!(result.is_err());
502    }
503
504    #[test]
505    fn test_value_type_roundtrip() {
506        assert_eq!("string".parse::<ValueType>().unwrap(), ValueType::String);
507        assert_eq!("list".parse::<ValueType>().unwrap(), ValueType::List);
508        assert_eq!("set".parse::<ValueType>().unwrap(), ValueType::Set);
509        assert!("hash".parse::<ValueType>().is_err());
510    }
511
512    #[test]
513    fn test_parse_branch_target() {
514        let t = Target::parse("branch:sc-branch-1-deadbeef").unwrap();
515        assert_eq!(t.target_type(), &TargetType::Branch);
516        assert_eq!(t.value(), Some("sc-branch-1-deadbeef"));
517    }
518
519    #[test]
520    fn test_decode_path_target_segments() {
521        let decoded =
522            super::decode_path_target_segments(&["src", "~__generated", "file.rs"]).unwrap();
523        assert_eq!(decoded, "src/__generated/file.rs");
524    }
525
526    #[test]
527    fn test_decode_key_path_segments() {
528        let decoded = super::decode_key_path_segments(&["agent", "model"]).unwrap();
529        assert_eq!(decoded, "agent:model");
530    }
531
532    #[test]
533    fn test_validate_key_rejects_reserved_segments() {
534        assert!(super::validate_key("agent:__value").is_err());
535        assert!(super::validate_key("__list:chat").is_err());
536        assert!(super::validate_key("__custom:model").is_err());
537    }
538
539    #[test]
540    fn test_validate_key_rejects_unsafe_segments() {
541        assert!(super::validate_key("agent:/model").is_err());
542        assert!(super::validate_key("agent::model").is_err());
543        assert!(super::validate_key("agent:.").is_err());
544        assert!(super::validate_key("agent:..").is_err());
545    }
546
547    #[test]
548    fn test_validate_key_accepts_normal_segments() {
549        assert!(super::validate_key("agent:model:version").is_ok());
550    }
551
552    #[test]
553    fn test_meta_value_string_type() {
554        let v = MetaValue::String("hello".to_string());
555        assert_eq!(v.value_type(), ValueType::String);
556    }
557
558    #[test]
559    fn test_meta_value_list_type() {
560        let v = MetaValue::List(vec![crate::list_value::ListEntry {
561            value: "item".to_string(),
562            timestamp: 1000,
563        }]);
564        assert_eq!(v.value_type(), ValueType::List);
565    }
566
567    #[test]
568    fn test_meta_value_set_type() {
569        let mut s = std::collections::BTreeSet::new();
570        s.insert("a".to_string());
571        s.insert("b".to_string());
572        let v = MetaValue::Set(s);
573        assert_eq!(v.value_type(), ValueType::Set);
574    }
575
576    #[test]
577    fn test_meta_value_empty_list_type() {
578        let v = MetaValue::List(vec![]);
579        assert_eq!(v.value_type(), ValueType::List);
580    }
581
582    #[test]
583    fn test_meta_value_empty_set_type() {
584        let v = MetaValue::Set(std::collections::BTreeSet::new());
585        assert_eq!(v.value_type(), ValueType::Set);
586    }
587
588    #[test]
589    fn test_meta_value_clone_eq() {
590        let v1 = MetaValue::String("test".to_string());
591        let v2 = v1.clone();
592        assert_eq!(v1, v2);
593    }
594
595    #[test]
596    fn test_target_commit_constructor() {
597        let t = Target::commit("abc123").unwrap();
598        assert_eq!(t.target_type(), &TargetType::Commit);
599        assert_eq!(t.value(), Some("abc123"));
600    }
601
602    #[test]
603    fn test_target_commit_constructor_short_sha_rejected() {
604        let result = Target::commit("ab");
605        assert!(result.is_err());
606    }
607
608    #[test]
609    fn test_target_project_constructor() {
610        let t = Target::project();
611        assert_eq!(t.target_type(), &TargetType::Project);
612        assert_eq!(t.value(), None);
613    }
614
615    #[test]
616    fn test_target_path_constructor() {
617        let t = Target::path("src/main.rs");
618        assert_eq!(t.target_type(), &TargetType::Path);
619        assert_eq!(t.value(), Some("src/main.rs"));
620    }
621
622    #[test]
623    fn test_target_branch_constructor() {
624        let t = Target::branch("feature-x");
625        assert_eq!(t.target_type(), &TargetType::Branch);
626        assert_eq!(t.value(), Some("feature-x"));
627    }
628
629    #[test]
630    fn test_target_change_id_constructor() {
631        let t = Target::change_id("jj-change-abc");
632        assert_eq!(t.target_type(), &TargetType::ChangeId);
633        assert_eq!(t.value(), Some("jj-change-abc"));
634    }
635
636    #[test]
637    fn test_named_constructors_match_parse() {
638        // Verify named constructors produce identical results to parse
639        let from_parse = Target::parse("commit:abc123").unwrap();
640        let from_ctor = Target::commit("abc123").unwrap();
641        assert_eq!(from_parse, from_ctor);
642
643        let from_parse = Target::parse("project").unwrap();
644        let from_ctor = Target::project();
645        assert_eq!(from_parse, from_ctor);
646
647        let from_parse = Target::parse("path:src/main.rs").unwrap();
648        let from_ctor = Target::path("src/main.rs");
649        assert_eq!(from_parse, from_ctor);
650
651        let from_parse = Target::parse("branch:feature-x").unwrap();
652        let from_ctor = Target::branch("feature-x");
653        assert_eq!(from_parse, from_ctor);
654
655        let from_parse = Target::parse("change-id:jj-change-abc").unwrap();
656        let from_ctor = Target::change_id("jj-change-abc");
657        assert_eq!(from_parse, from_ctor);
658    }
659}