1use serde::{Deserialize, Serialize};
2use std::borrow::Borrow;
3use std::fmt;
4use std::hash::{Hash, Hasher};
5use std::ops::Deref;
6use std::path::{Component, Path, PathBuf};
7
8macro_rules! string_newtype {
9 ($(#[$meta:meta])* $name:ident) => {
10 $(#[$meta])*
11 #[derive(
12 Serialize, Deserialize, Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd,
13 )]
14 #[serde(transparent)]
15 pub struct $name(String);
16
17 impl $name {
18 pub fn new(value: impl Into<String>) -> Self {
19 Self(value.into())
20 }
21
22 pub fn as_str(&self) -> &str {
23 &self.0
24 }
25
26 pub fn into_inner(self) -> String {
27 self.0
28 }
29 }
30
31 impl From<String> for $name {
32 fn from(value: String) -> Self {
33 Self(value)
34 }
35 }
36
37 impl From<&str> for $name {
38 fn from(value: &str) -> Self {
39 Self(value.to_owned())
40 }
41 }
42
43 impl AsRef<str> for $name {
44 fn as_ref(&self) -> &str {
45 &self.0
46 }
47 }
48
49 impl Borrow<str> for $name {
50 fn borrow(&self) -> &str {
51 &self.0
52 }
53 }
54
55 impl Deref for $name {
56 type Target = str;
57
58 fn deref(&self) -> &Self::Target {
59 &self.0
60 }
61 }
62
63 impl From<$name> for String {
64 fn from(value: $name) -> Self {
65 value.0
66 }
67 }
68
69 impl fmt::Display for $name {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 f.write_str(&self.0)
72 }
73 }
74
75 impl PartialEq<str> for $name {
76 fn eq(&self, other: &str) -> bool {
77 self.0 == other
78 }
79 }
80
81 impl PartialEq<&str> for $name {
82 fn eq(&self, other: &&str) -> bool {
83 self.0 == *other
84 }
85 }
86
87 impl PartialEq<String> for $name {
88 fn eq(&self, other: &String) -> bool {
89 self.0 == *other
90 }
91 }
92
93 impl PartialEq<$name> for String {
94 fn eq(&self, other: &$name) -> bool {
95 *self == other.0
96 }
97 }
98 };
99}
100
101string_newtype!(SourceName);
102string_newtype!(ItemName);
103string_newtype!(SourceUrl);
104string_newtype!(CommitHash);
105string_newtype!(ContentHash);
106
107#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
109pub struct SourceSubpath(String);
110
111impl SourceSubpath {
112 pub fn new(value: impl AsRef<str>) -> Result<Self, SourceSubpathError> {
113 let raw = value.as_ref();
114 if raw.is_empty() {
115 return Err(SourceSubpathError::Empty);
116 }
117
118 let normalized_separators = raw.replace('\\', "/");
119 if is_windows_absolute(&normalized_separators) {
120 return Err(SourceSubpathError::Absolute {
121 input: raw.to_string(),
122 });
123 }
124
125 let mut segments = Vec::new();
126 for component in Path::new(&normalized_separators).components() {
127 match component {
128 Component::Normal(seg) => segments.push(seg.to_string_lossy().into_owned()),
129 Component::CurDir => {}
130 Component::ParentDir => {
131 return Err(SourceSubpathError::Escaping {
132 input: raw.to_string(),
133 });
134 }
135 Component::RootDir | Component::Prefix(_) => {
136 return Err(SourceSubpathError::Absolute {
137 input: raw.to_string(),
138 });
139 }
140 }
141 }
142
143 if segments.is_empty() {
144 return Err(SourceSubpathError::Empty);
145 }
146
147 Ok(Self(segments.join("/")))
148 }
149
150 pub fn as_str(&self) -> &str {
151 &self.0
152 }
153
154 pub fn as_path(&self) -> &Path {
155 Path::new(&self.0)
156 }
157
158 pub fn into_inner(self) -> String {
159 self.0
160 }
161
162 pub fn join_under(&self, base: &Path) -> Result<PathBuf, SourceSubpathError> {
164 let mut joined = base.to_path_buf();
165 for component in self.as_path().components() {
166 match component {
167 Component::Normal(seg) => joined.push(seg),
168 Component::CurDir => {}
169 Component::ParentDir => {
170 return Err(SourceSubpathError::Escaping {
171 input: self.0.clone(),
172 });
173 }
174 Component::RootDir | Component::Prefix(_) => {
175 return Err(SourceSubpathError::Absolute {
176 input: self.0.clone(),
177 });
178 }
179 }
180 }
181
182 if joined.strip_prefix(base).is_err() {
183 return Err(SourceSubpathError::Escaping {
184 input: self.0.clone(),
185 });
186 }
187
188 Ok(joined)
189 }
190}
191
192impl fmt::Display for SourceSubpath {
193 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194 f.write_str(&self.0)
195 }
196}
197
198impl AsRef<str> for SourceSubpath {
199 fn as_ref(&self) -> &str {
200 self.as_str()
201 }
202}
203
204impl std::str::FromStr for SourceSubpath {
205 type Err = SourceSubpathError;
206
207 fn from_str(s: &str) -> Result<Self, Self::Err> {
208 Self::new(s)
209 }
210}
211
212impl Serialize for SourceSubpath {
213 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
214 self.0.serialize(serializer)
215 }
216}
217
218impl<'de> Deserialize<'de> for SourceSubpath {
219 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
220 let value = String::deserialize(deserializer)?;
221 SourceSubpath::new(value).map_err(serde::de::Error::custom)
222 }
223}
224
225#[derive(Debug, thiserror::Error, PartialEq, Eq)]
226pub enum SourceSubpathError {
227 #[error("subpath cannot be empty")]
228 Empty,
229 #[error("subpath must be relative, got absolute value: {input:?}")]
230 Absolute { input: String },
231 #[error("subpath cannot escape package root: {input:?}")]
232 Escaping { input: String },
233}
234
235fn is_windows_absolute(path: &str) -> bool {
236 let bytes = path.as_bytes();
237 if path.starts_with('/') {
238 return true;
239 }
240 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
241 return true;
242 }
243 false
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum SourceOrigin {
249 Dependency(SourceName),
251 LocalPackage,
253}
254
255impl fmt::Display for SourceOrigin {
256 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257 match self {
258 Self::Dependency(name) => write!(f, "{name}"),
259 Self::LocalPackage => write!(f, "_self"),
260 }
261 }
262}
263
264#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
266#[serde(rename_all = "lowercase")]
267pub enum ItemKind {
268 Agent,
269 Skill,
270}
271
272impl fmt::Display for ItemKind {
273 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274 match self {
275 ItemKind::Agent => write!(f, "agent"),
276 ItemKind::Skill => write!(f, "skill"),
277 }
278 }
279}
280
281#[derive(Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
286pub struct ItemId {
287 pub kind: ItemKind,
288 pub name: ItemName,
289}
290
291impl fmt::Display for ItemId {
292 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293 write!(f, "{}/{}", self.kind, self.name)
294 }
295}
296
297#[derive(Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
299pub struct DestPath(PathBuf);
300
301impl DestPath {
302 pub fn new(value: impl Into<PathBuf>) -> Self {
303 Self(value.into())
304 }
305
306 pub fn as_path(&self) -> &Path {
307 &self.0
308 }
309
310 pub fn into_inner(self) -> PathBuf {
311 self.0
312 }
313
314 pub fn resolve(&self, root: &Path) -> PathBuf {
316 root.join(&self.0)
317 }
318}
319
320impl From<PathBuf> for DestPath {
321 fn from(value: PathBuf) -> Self {
322 Self(value)
323 }
324}
325
326impl From<&Path> for DestPath {
327 fn from(value: &Path) -> Self {
328 Self(value.to_path_buf())
329 }
330}
331
332impl From<&str> for DestPath {
333 fn from(value: &str) -> Self {
334 Self(PathBuf::from(value))
335 }
336}
337
338impl From<String> for DestPath {
339 fn from(value: String) -> Self {
340 Self(PathBuf::from(value))
341 }
342}
343
344impl AsRef<Path> for DestPath {
345 fn as_ref(&self) -> &Path {
346 &self.0
347 }
348}
349
350impl Borrow<Path> for DestPath {
351 fn borrow(&self) -> &Path {
352 &self.0
353 }
354}
355
356impl Borrow<str> for DestPath {
357 fn borrow(&self) -> &str {
358 self.0.to_str().expect("DestPath must be valid UTF-8")
359 }
360}
361
362impl Hash for DestPath {
363 fn hash<H: Hasher>(&self, state: &mut H) {
364 self.0.to_string_lossy().hash(state);
365 }
366}
367
368impl Deref for DestPath {
369 type Target = Path;
370
371 fn deref(&self) -> &Self::Target {
372 &self.0
373 }
374}
375
376impl fmt::Display for DestPath {
377 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
378 write!(f, "{}", self.0.display())
379 }
380}
381
382impl Serialize for DestPath {
383 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
384 self.0.to_string_lossy().serialize(serializer)
385 }
386}
387
388impl<'de> Deserialize<'de> for DestPath {
389 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
390 String::deserialize(deserializer).map(|s| Self(PathBuf::from(s)))
391 }
392}
393
394#[derive(Debug, Clone)]
398pub struct MarsContext {
399 pub project_root: PathBuf,
401 pub managed_root: PathBuf,
403}
404
405#[cfg(test)]
406impl MarsContext {
407 pub fn for_test(project_root: PathBuf, managed_root: PathBuf) -> Self {
409 MarsContext {
410 project_root,
411 managed_root,
412 }
413 }
414}
415
416#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd, Serialize, Deserialize)]
418pub enum SourceId {
419 Git {
420 url: SourceUrl,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
422 subpath: Option<SourceSubpath>,
423 },
424 Path {
425 canonical: PathBuf,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
427 subpath: Option<SourceSubpath>,
428 },
429}
430
431impl SourceId {
432 pub fn git(url: SourceUrl) -> Self {
433 Self::Git { url, subpath: None }
434 }
435
436 pub fn git_with_subpath(url: SourceUrl, subpath: Option<SourceSubpath>) -> Self {
437 Self::Git { url, subpath }
438 }
439
440 pub fn path(base: &Path, relative_or_absolute: &Path) -> std::io::Result<Self> {
441 Self::path_with_subpath(base, relative_or_absolute, None)
442 }
443
444 pub fn path_with_subpath(
445 base: &Path,
446 relative_or_absolute: &Path,
447 subpath: Option<SourceSubpath>,
448 ) -> std::io::Result<Self> {
449 let candidate = if relative_or_absolute.is_absolute() {
450 relative_or_absolute.to_path_buf()
451 } else {
452 base.join(relative_or_absolute)
453 };
454 let canonical = candidate.canonicalize()?;
455 Ok(Self::Path { canonical, subpath })
456 }
457}
458
459impl fmt::Display for SourceId {
460 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461 match self {
462 Self::Git { url, subpath } => {
463 write!(f, "git:{url}")?;
464 if let Some(subpath) = subpath {
465 write!(f, "@{subpath}")?;
466 }
467 Ok(())
468 }
469 Self::Path { canonical, subpath } => {
470 write!(f, "path:{}", canonical.display())?;
471 if let Some(subpath) = subpath {
472 write!(f, "@{subpath}")?;
473 }
474 Ok(())
475 }
476 }
477 }
478}
479
480#[derive(Debug, Clone, PartialEq, Eq)]
481pub struct RenameRule {
482 pub from: ItemName,
483 pub to: ItemName,
484}
485
486#[derive(Debug, Clone, Default, PartialEq, Eq)]
488pub struct RenameMap(Vec<RenameRule>);
489
490impl RenameMap {
491 pub fn new() -> Self {
492 Self(Vec::new())
493 }
494
495 pub fn insert(&mut self, from: ItemName, to: ItemName) {
496 if let Some(existing) = self.0.iter_mut().find(|r| r.from == from) {
497 existing.to = to;
498 return;
499 }
500 self.0.push(RenameRule { from, to });
501 }
502
503 pub fn push(&mut self, rule: RenameRule) {
504 self.insert(rule.from, rule.to);
505 }
506
507 pub fn get(&self, from: &str) -> Option<&ItemName> {
508 self.0.iter().find(|r| r.from == from).map(|r| &r.to)
509 }
510
511 pub fn iter(&self) -> impl Iterator<Item = &RenameRule> {
512 self.0.iter()
513 }
514
515 pub fn is_empty(&self) -> bool {
516 self.0.is_empty()
517 }
518
519 pub fn len(&self) -> usize {
520 self.0.len()
521 }
522}
523
524impl Serialize for RenameMap {
525 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
526 use serde::ser::SerializeMap;
527 let mut map = serializer.serialize_map(Some(self.0.len()))?;
528 for rule in &self.0 {
529 map.serialize_entry(rule.from.as_str(), rule.to.as_str())?;
530 }
531 map.end()
532 }
533}
534
535impl<'de> Deserialize<'de> for RenameMap {
536 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
537 let map = indexmap::IndexMap::<String, String>::deserialize(deserializer)?;
538 Ok(Self(
539 map.into_iter()
540 .map(|(from, to)| RenameRule {
541 from: ItemName::from(from),
542 to: ItemName::from(to),
543 })
544 .collect(),
545 ))
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use serde::{Deserialize, Serialize};
553 use std::path::PathBuf;
554
555 #[derive(Debug, Serialize, Deserialize, PartialEq)]
556 struct Wrapper<T> {
557 value: T,
558 }
559
560 #[test]
561 fn dest_path_roundtrip() {
562 let v = Wrapper {
563 value: DestPath::from("agents/coder.md"),
564 };
565 let s = toml::to_string(&v).unwrap();
566 let out: Wrapper<DestPath> = toml::from_str(&s).unwrap();
567 assert_eq!(v, out);
568 }
569
570 #[test]
571 fn rename_map_toml_roundtrip_compat() {
572 #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
573 struct RenameWrapper {
574 rename: RenameMap,
575 }
576
577 let input = r#"rename = { "coder" = "cool-coder" }"#;
578 let parsed: RenameWrapper = toml::from_str(input).unwrap();
579 assert_eq!(
580 parsed.rename.get("coder").map(|v| v.as_str()),
581 Some("cool-coder")
582 );
583
584 let serialized = toml::to_string(&parsed).unwrap();
585 let reparsed: RenameWrapper = toml::from_str(&serialized).unwrap();
586 assert_eq!(parsed, reparsed);
587 }
588
589 #[test]
590 fn source_subpath_normalizes_windows_and_unix_separators() {
591 let subpath = SourceSubpath::new(r"plugins\foo/bar\baz").unwrap();
592 assert_eq!(subpath.as_str(), "plugins/foo/bar/baz");
593 }
594
595 #[test]
596 fn source_subpath_rejects_empty() {
597 let err = SourceSubpath::new("").unwrap_err();
598 assert_eq!(err, SourceSubpathError::Empty);
599 }
600
601 #[test]
602 fn source_subpath_rejects_absolute() {
603 let err = SourceSubpath::new("/abs/path").unwrap_err();
604 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
605 }
606
607 #[test]
608 fn source_subpath_rejects_root_only() {
609 let err = SourceSubpath::new("/").unwrap_err();
610 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
611 }
612
613 #[test]
614 fn source_subpath_rejects_windows_absolute() {
615 let err = SourceSubpath::new(r"C:\abs\path").unwrap_err();
616 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
617 }
618
619 #[test]
620 fn source_subpath_rejects_escape() {
621 let err = SourceSubpath::new("../escape").unwrap_err();
622 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
623 }
624
625 #[test]
626 fn source_subpath_accepts_nested_relative_path() {
627 let subpath = SourceSubpath::new("a/b/c").unwrap();
628 assert_eq!(subpath.as_str(), "a/b/c");
629 }
630
631 #[test]
632 fn source_subpath_accepts_plugins_foo() {
633 let subpath = SourceSubpath::new("plugins/foo").unwrap();
634 assert_eq!(subpath.as_str(), "plugins/foo");
635 }
636
637 #[test]
638 fn source_subpath_serializes_with_forward_slashes() {
639 #[derive(Debug, Serialize, Deserialize, PartialEq)]
640 struct SubpathWrapper {
641 subpath: SourceSubpath,
642 }
643
644 let wrapper = SubpathWrapper {
645 subpath: SourceSubpath::new(r"plugins\foo").unwrap(),
646 };
647 let toml = toml::to_string(&wrapper).unwrap();
648 assert!(toml.contains("subpath = \"plugins/foo\""));
649 }
650
651 #[test]
652 fn source_subpath_join_under_base() {
653 let base = PathBuf::from("/tmp/mars");
654 let subpath = SourceSubpath::new("plugins/foo").unwrap();
655 let joined = subpath.join_under(&base).unwrap();
656 assert_eq!(joined, base.join("plugins").join("foo"));
657 }
658
659 #[test]
660 fn source_subpath_join_under_rejects_escape_path() {
661 let escaped = SourceSubpath(String::from("../escape"));
662 let err = escaped.join_under(Path::new("/tmp/base")).unwrap_err();
663 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
664 }
665
666 #[test]
670 fn source_subpath_accepts_deeply_nested() {
671 let subpath = SourceSubpath::new("a/b/c/d/e").unwrap();
672 assert_eq!(subpath.as_str(), "a/b/c/d/e");
673 }
674
675 #[test]
677 fn source_subpath_rejects_windows_drive_forward_slash() {
678 let err = SourceSubpath::new("C:/foo").unwrap_err();
679 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
680 }
681
682 #[test]
684 fn source_subpath_rejects_current_dir_dot() {
685 let err = SourceSubpath::new(".").unwrap_err();
686 assert_eq!(err, SourceSubpathError::Empty);
687 }
688
689 #[test]
692 fn source_subpath_rejects_mid_path_double_parent_escape() {
693 let err = SourceSubpath::new("a/../../escape").unwrap_err();
694 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
695 }
696
697 #[test]
700 fn source_subpath_rejects_harmless_parent_in_middle() {
701 let err = SourceSubpath::new("a/b/../c").unwrap_err();
702 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
703 }
704
705 #[test]
707 fn source_subpath_normalizes_trailing_slash() {
708 let subpath = SourceSubpath::new("plugins/foo/").unwrap();
709 assert_eq!(subpath.as_str(), "plugins/foo");
710 }
711
712 #[test]
714 fn source_subpath_normalizes_leading_dot_slash() {
715 let subpath = SourceSubpath::new("./plugins/foo").unwrap();
716 assert_eq!(subpath.as_str(), "plugins/foo");
717 }
718
719 #[test]
721 fn source_subpath_join_under_base_with_trailing_slash() {
722 let base = PathBuf::from("/tmp/mars/");
723 let subpath = SourceSubpath::new("plugins/foo").unwrap();
724 let joined = subpath.join_under(&base).unwrap();
725 assert_eq!(joined, PathBuf::from("/tmp/mars/plugins/foo"));
727 }
728
729 #[test]
731 fn locked_source_json_roundtrip_without_subpath() {
732 let json = r#"{"url":"https://github.com/org/base.git"}"#;
733 let parsed: crate::lock::LockedSource = serde_json::from_str(json).unwrap();
734 assert!(parsed.subpath.is_none());
735 }
736
737 #[test]
739 fn locked_source_json_roundtrip_with_subpath() {
740 let source = crate::lock::LockedSource {
741 url: Some(SourceUrl::from("https://github.com/org/base.git")),
742 path: None,
743 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
744 version: None,
745 commit: None,
746 tree_hash: None,
747 };
748 let json = serde_json::to_string(&source).unwrap();
749 assert!(json.contains("\"subpath\":\"plugins/foo\""));
750 let reparsed: crate::lock::LockedSource = serde_json::from_str(&json).unwrap();
751 assert_eq!(
752 reparsed.subpath.as_ref().map(SourceSubpath::as_str),
753 Some("plugins/foo")
754 );
755 }
756
757 #[test]
759 fn locked_source_toml_missing_subpath_field_is_none() {
760 let toml_str = r#"
761version = 1
762
763[dependencies.dep]
764url = "https://github.com/org/dep.git"
765commit = "deadbeef"
766"#;
767 let lock: crate::lock::LockFile = toml::from_str(toml_str).unwrap();
768 assert!(lock.dependencies["dep"].subpath.is_none());
769 }
770
771 #[test]
773 fn locked_source_toml_subpath_serializes_alongside_other_fields() {
774 let source = crate::lock::LockedSource {
775 url: Some(SourceUrl::from("https://github.com/org/base.git")),
776 path: None,
777 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
778 version: Some("v1.0.0".to_string()),
779 commit: Some(CommitHash::from("abc123")),
780 tree_hash: None,
781 };
782 #[derive(Serialize)]
783 struct Wrapper {
784 source: crate::lock::LockedSource,
785 }
786 let serialized = toml::to_string(&Wrapper { source }).unwrap();
787 assert!(serialized.contains("subpath = \"plugins/foo\""));
788 assert!(serialized.contains("url = "));
789 assert!(serialized.contains("commit = "));
790 }
791
792 #[test]
793 fn lock_roundtrip_with_and_without_subpath() {
794 let old_lock = r#"
795version = 1
796
797[dependencies.base]
798url = "https://github.com/org/base.git"
799"#;
800 let parsed_old: crate::lock::LockFile = toml::from_str(old_lock).unwrap();
801 assert!(parsed_old.dependencies["base"].subpath.is_none());
802
803 let lock = crate::lock::LockFile {
804 version: 1,
805 dependencies: indexmap::IndexMap::from([(
806 SourceName::from("base"),
807 crate::lock::LockedSource {
808 url: Some(SourceUrl::from("https://github.com/org/base.git")),
809 path: None,
810 subpath: Some(SourceSubpath::new(r"plugins\foo").unwrap()),
811 version: Some("v1.2.3".to_string()),
812 commit: Some(CommitHash::from("abc123")),
813 tree_hash: None,
814 },
815 )]),
816 items: indexmap::IndexMap::new(),
817 };
818 let serialized = toml::to_string_pretty(&lock).unwrap();
819 assert!(serialized.contains("subpath = \"plugins/foo\""));
820 let reparsed: crate::lock::LockFile = toml::from_str(&serialized).unwrap();
821 assert_eq!(
822 reparsed.dependencies["base"]
823 .subpath
824 .as_ref()
825 .map(SourceSubpath::as_str),
826 Some("plugins/foo")
827 );
828 }
829
830 #[test]
831 fn config_roundtrip_preserves_subpath() {
832 let config = r#"
833[dependencies.base]
834url = "https://github.com/org/base.git"
835subpath = "plugins\\foo"
836"#;
837 let parsed: crate::config::Config = toml::from_str(config).unwrap();
838 assert_eq!(
839 parsed.dependencies["base"]
840 .subpath
841 .as_ref()
842 .map(SourceSubpath::as_str),
843 Some("plugins/foo")
844 );
845
846 let serialized = toml::to_string(&parsed).unwrap();
847 assert!(serialized.contains("subpath = \"plugins/foo\""));
848 let reparsed: crate::config::Config = toml::from_str(&serialized).unwrap();
849 assert_eq!(
850 reparsed.dependencies["base"]
851 .subpath
852 .as_ref()
853 .map(SourceSubpath::as_str),
854 Some("plugins/foo")
855 );
856 }
857
858 #[test]
859 fn source_id_git_same_url_same_subpath_are_equal_and_hash_equal() {
860 let a = SourceId::git_with_subpath(
861 SourceUrl::from("https://example.com/repo.git"),
862 Some(SourceSubpath::new("plugins/foo").unwrap()),
863 );
864 let b = SourceId::git_with_subpath(
865 SourceUrl::from("https://example.com/repo.git"),
866 Some(SourceSubpath::new("plugins/foo").unwrap()),
867 );
868
869 assert_eq!(a, b);
870
871 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
872 a.hash(&mut hasher_a);
873 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
874 b.hash(&mut hasher_b);
875 assert_eq!(hasher_a.finish(), hasher_b.finish());
876 }
877
878 #[test]
879 fn source_id_git_same_url_different_subpaths_are_distinct() {
880 let a = SourceId::git_with_subpath(
881 SourceUrl::from("https://example.com/repo.git"),
882 Some(SourceSubpath::new("plugins/foo").unwrap()),
883 );
884 let b = SourceId::git_with_subpath(
885 SourceUrl::from("https://example.com/repo.git"),
886 Some(SourceSubpath::new("plugins/bar").unwrap()),
887 );
888
889 assert_ne!(a, b);
890
891 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
892 a.hash(&mut hasher_a);
893 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
894 b.hash(&mut hasher_b);
895 assert_ne!(hasher_a.finish(), hasher_b.finish());
896 }
897
898 #[test]
904 fn source_id_path_none_and_some_subpath_hash_distinctly() {
905 let canonical = PathBuf::from("/tmp/my-repo");
906 let a = SourceId::Path {
907 canonical: canonical.clone(),
908 subpath: None,
909 };
910 let b = SourceId::Path {
911 canonical: canonical.clone(),
912 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
913 };
914
915 assert_ne!(a, b);
916
917 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
918 a.hash(&mut hasher_a);
919 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
920 b.hash(&mut hasher_b);
921 assert_ne!(hasher_a.finish(), hasher_b.finish());
922 }
923
924 #[test]
927 fn source_id_path_same_canonical_same_subpath_are_equal() {
928 let canonical = PathBuf::from("/tmp/my-repo");
929 let a = SourceId::Path {
930 canonical: canonical.clone(),
931 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
932 };
933 let b = SourceId::Path {
934 canonical: canonical.clone(),
935 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
936 };
937
938 assert_eq!(a, b);
939
940 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
941 a.hash(&mut hasher_a);
942 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
943 b.hash(&mut hasher_b);
944 assert_eq!(hasher_a.finish(), hasher_b.finish());
945 }
946
947 #[test]
950 fn source_id_path_same_canonical_different_subpaths_are_distinct() {
951 let canonical = PathBuf::from("/tmp/my-repo");
952 let a = SourceId::Path {
953 canonical: canonical.clone(),
954 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
955 };
956 let b = SourceId::Path {
957 canonical: canonical.clone(),
958 subpath: Some(SourceSubpath::new("plugins/bar").unwrap()),
959 };
960
961 assert_ne!(a, b);
962
963 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
964 a.hash(&mut hasher_a);
965 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
966 b.hash(&mut hasher_b);
967 assert_ne!(hasher_a.finish(), hasher_b.finish());
968 }
969
970 #[test]
976 fn lock_write_and_load_roundtrip_preserves_subpath() {
977 use crate::lock::{LockFile, LockedSource};
978 use tempfile::TempDir;
979
980 let dir = TempDir::new().unwrap();
981 let lock = LockFile {
982 version: 1,
983 dependencies: indexmap::IndexMap::from([(
984 SourceName::from("dep"),
985 LockedSource {
986 url: Some(SourceUrl::from("https://github.com/org/repo.git")),
987 path: None,
988 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
989 version: Some("v1.2.3".to_string()),
990 commit: Some(CommitHash::from("deadbeef")),
991 tree_hash: None,
992 },
993 )]),
994 items: indexmap::IndexMap::new(),
995 };
996
997 crate::lock::write(dir.path(), &lock).unwrap();
998 let loaded = crate::lock::load(dir.path()).unwrap();
999
1000 assert_eq!(
1001 loaded.dependencies["dep"]
1002 .subpath
1003 .as_ref()
1004 .map(SourceSubpath::as_str),
1005 Some("plugins/foo")
1006 );
1007 assert_eq!(
1008 loaded.dependencies["dep"].url.as_deref(),
1009 Some("https://github.com/org/repo.git")
1010 );
1011 assert_eq!(
1012 loaded.dependencies["dep"].version.as_deref(),
1013 Some("v1.2.3")
1014 );
1015 }
1016
1017 #[test]
1023 fn effective_dependency_subpath_preserved_through_merge() {
1024 use crate::config::{Config, merge};
1025
1026 let toml_str = r#"
1027[dependencies.dep]
1028url = "https://github.com/org/repo.git"
1029subpath = "plugins/foo"
1030"#;
1031 let config: Config = toml::from_str(toml_str).unwrap();
1032 let effective = merge(config, crate::config::LocalConfig::default()).unwrap();
1033 assert_eq!(
1034 effective.dependencies["dep"]
1035 .subpath
1036 .as_ref()
1037 .map(SourceSubpath::as_str),
1038 Some("plugins/foo")
1039 );
1040 assert!(matches!(
1042 &effective.dependencies["dep"].id,
1043 SourceId::Git { subpath: Some(sp), .. } if sp.as_str() == "plugins/foo"
1044 ));
1045 }
1046}