1use std::fmt;
2use std::str::FromStr;
3
4use sha1::{Digest, Sha1};
5
6use crate::error::{Error, Result};
7
8#[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 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 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#[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 #[must_use]
93 pub fn from_parts(target_type: TargetType, value: Option<String>) -> Self {
94 Target { target_type, value }
95 }
96
97 pub fn commit(sha: &str) -> Result<Self> {
105 Self::parse(&format!("commit:{sha}"))
106 }
107
108 #[must_use]
110 pub fn project() -> Self {
111 Target {
112 target_type: TargetType::Project,
113 value: None,
114 }
115 }
116
117 #[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 #[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 #[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 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 #[must_use]
200 pub fn target_type(&self) -> &TargetType {
201 &self.target_type
202 }
203
204 #[must_use]
208 pub fn value(&self) -> Option<&str> {
209 self.value.as_deref()
210 }
211
212 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#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
276#[non_exhaustive]
277pub enum MetaValue {
278 String(String),
280 List(Vec<crate::ListEntry>),
282 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 #[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#[derive(Debug, Clone)]
334#[non_exhaustive]
335pub enum MetaEdit<'a> {
336 ListAppend {
338 key: &'a str,
340 entries: &'a [crate::ListEntry],
342 },
343 SetAdd {
345 key: &'a str,
347 members: &'a [String],
349 },
350}
351
352impl<'a> MetaEdit<'a> {
353 #[must_use]
359 pub fn list_append(key: &'a str, entries: &'a [crate::ListEntry]) -> Self {
360 Self::ListAppend { key, entries }
361 }
362
363 #[must_use]
365 pub fn set_add(key: &'a str, members: &'a [String]) -> Self {
366 Self::SetAdd { key, members }
367 }
368}
369
370#[cfg(not(feature = "internal"))]
372pub(crate) const GIT_REF_THRESHOLD: usize = 1024;
373#[cfg(feature = "internal")]
375pub const GIT_REF_THRESHOLD: usize = 1024;
376
377pub(crate) const STRING_VALUE_BLOB: &str = "__value";
379
380pub(crate) const LIST_VALUE_DIR: &str = "__list";
382
383pub(crate) const SET_VALUE_DIR: &str = "__set";
385
386pub(crate) const TOMBSTONE_ROOT: &str = "__tombstones";
388
389pub(crate) const TOMBSTONE_BLOB: &str = "__deleted";
391
392pub(crate) const PATH_TARGET_SEPARATOR: &str = "__target__";
394
395pub(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
418pub(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#[cfg(not(feature = "internal"))]
464pub(crate) fn validate_key(key: &str) -> Result<()> {
465 validate_key_inner(key)
466}
467
468#[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
488pub(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 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 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}