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
107enum NormalizeError {
109 Empty,
110 Absolute,
111 Escaping,
112}
113
114fn normalize_relative_coordinate(raw: &str) -> Result<String, NormalizeError> {
117 let normalized_separators = raw.replace('\\', "/");
118
119 let mut segments = Vec::new();
120 for component in Path::new(&normalized_separators).components() {
121 match component {
122 Component::Normal(seg) => segments.push(seg.to_string_lossy().into_owned()),
123 Component::CurDir => {}
124 Component::ParentDir => return Err(NormalizeError::Escaping),
125 Component::RootDir | Component::Prefix(_) => return Err(NormalizeError::Absolute),
126 }
127 }
128
129 if segments.is_empty() {
130 return Err(NormalizeError::Empty);
131 }
132
133 Ok(segments.join("/"))
134}
135
136#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
138pub struct SourceSubpath(String);
139
140impl SourceSubpath {
141 pub fn new(value: impl AsRef<str>) -> Result<Self, SourceSubpathError> {
142 let raw = value.as_ref();
143 if raw.is_empty() {
144 return Err(SourceSubpathError::Empty);
145 }
146
147 let normalized_separators = raw.replace('\\', "/");
148 if is_windows_absolute(&normalized_separators) {
149 return Err(SourceSubpathError::Absolute {
150 input: raw.to_string(),
151 });
152 }
153
154 let normalized = normalize_relative_coordinate(raw).map_err(|err| match err {
155 NormalizeError::Empty => SourceSubpathError::Empty,
156 NormalizeError::Absolute => SourceSubpathError::Absolute {
157 input: raw.to_string(),
158 },
159 NormalizeError::Escaping => SourceSubpathError::Escaping {
160 input: raw.to_string(),
161 },
162 })?;
163
164 Ok(Self(normalized))
165 }
166
167 pub fn as_str(&self) -> &str {
168 &self.0
169 }
170
171 pub fn as_path(&self) -> &Path {
172 Path::new(&self.0)
173 }
174
175 pub fn into_inner(self) -> String {
176 self.0
177 }
178
179 pub fn join_under(&self, base: &Path) -> Result<PathBuf, SourceSubpathError> {
181 let mut joined = base.to_path_buf();
182 for component in self.as_path().components() {
183 match component {
184 Component::Normal(seg) => joined.push(seg),
185 Component::CurDir => {}
186 Component::ParentDir => {
187 return Err(SourceSubpathError::Escaping {
188 input: self.0.clone(),
189 });
190 }
191 Component::RootDir | Component::Prefix(_) => {
192 return Err(SourceSubpathError::Absolute {
193 input: self.0.clone(),
194 });
195 }
196 }
197 }
198
199 if joined.strip_prefix(base).is_err() {
200 return Err(SourceSubpathError::Escaping {
201 input: self.0.clone(),
202 });
203 }
204
205 Ok(joined)
206 }
207}
208
209impl fmt::Display for SourceSubpath {
210 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211 f.write_str(&self.0)
212 }
213}
214
215impl AsRef<str> for SourceSubpath {
216 fn as_ref(&self) -> &str {
217 self.as_str()
218 }
219}
220
221impl std::str::FromStr for SourceSubpath {
222 type Err = SourceSubpathError;
223
224 fn from_str(s: &str) -> Result<Self, Self::Err> {
225 Self::new(s)
226 }
227}
228
229impl Serialize for SourceSubpath {
230 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
231 self.0.serialize(serializer)
232 }
233}
234
235impl<'de> Deserialize<'de> for SourceSubpath {
236 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
237 let value = String::deserialize(deserializer)?;
238 SourceSubpath::new(value).map_err(serde::de::Error::custom)
239 }
240}
241
242#[derive(Debug, thiserror::Error, PartialEq, Eq)]
243pub enum SourceSubpathError {
244 #[error("subpath cannot be empty")]
245 Empty,
246 #[error("subpath must be relative, got absolute value: {input:?}")]
247 Absolute { input: String },
248 #[error("subpath cannot escape package root: {input:?}")]
249 Escaping { input: String },
250}
251
252#[derive(Debug, thiserror::Error, PartialEq, Eq)]
253pub enum DestPathError {
254 #[error("destination path cannot be empty")]
255 Empty,
256 #[error("destination path must be relative, got absolute value: {input:?}")]
257 Absolute { input: String },
258 #[error("destination path cannot escape target root: {input:?}")]
259 Escaping { input: String },
260 #[error("cannot convert path to DestPath: {reason}")]
261 ConversionFailed { reason: String },
262}
263
264fn is_windows_absolute(path: &str) -> bool {
265 let bytes = path.as_bytes();
266 if path.starts_with('/') {
267 return true;
268 }
269 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
270 return true;
271 }
272 false
273}
274
275fn is_windows_drive_relative(path: &str) -> bool {
276 let bytes = path.as_bytes();
277 bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
282pub enum SourceOrigin {
283 Dependency(SourceName),
285 LocalPackage,
287}
288
289impl fmt::Display for SourceOrigin {
290 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291 match self {
292 Self::Dependency(name) => write!(f, "{name}"),
293 Self::LocalPackage => write!(f, "_self"),
294 }
295 }
296}
297
298#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
300#[serde(rename_all = "lowercase")]
301pub enum ItemKind {
302 Agent,
303 Skill,
304}
305
306impl fmt::Display for ItemKind {
307 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308 match self {
309 ItemKind::Agent => write!(f, "agent"),
310 ItemKind::Skill => write!(f, "skill"),
311 }
312 }
313}
314
315#[derive(Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
320pub struct ItemId {
321 pub kind: ItemKind,
322 pub name: ItemName,
323}
324
325impl fmt::Display for ItemId {
326 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327 write!(f, "{}/{}", self.kind, self.name)
328 }
329}
330
331#[derive(Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
334pub struct DestPath(String);
335
336impl DestPath {
337 pub fn new(value: impl AsRef<str>) -> Result<Self, DestPathError> {
339 let raw = value.as_ref();
340 if raw.is_empty() {
341 return Err(DestPathError::Empty);
342 }
343
344 let normalized_separators = raw.replace('\\', "/");
345 if is_windows_absolute(&normalized_separators)
346 || is_windows_drive_relative(&normalized_separators)
347 {
348 return Err(DestPathError::Absolute {
349 input: raw.to_string(),
350 });
351 }
352
353 let normalized = normalize_relative_coordinate(raw).map_err(|err| match err {
354 NormalizeError::Empty => DestPathError::Empty,
355 NormalizeError::Absolute => DestPathError::Absolute {
356 input: raw.to_string(),
357 },
358 NormalizeError::Escaping => DestPathError::Escaping {
359 input: raw.to_string(),
360 },
361 })?;
362
363 Ok(Self(normalized))
364 }
365
366 pub fn as_str(&self) -> &str {
368 &self.0
369 }
370
371 pub fn into_inner(self) -> String {
373 self.0
374 }
375
376 pub fn resolve(&self, root: &Path) -> PathBuf {
378 let mut result = root.to_path_buf();
379 for component in self.components() {
380 result.push(component);
381 }
382 result
383 }
384
385 pub fn components(&self) -> impl Iterator<Item = &str> {
387 self.0.split('/')
388 }
389
390 pub fn item_name(&self, kind: ItemKind) -> String {
393 let last = self.0.rsplit('/').next().unwrap_or("");
394 match kind {
395 ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
396 ItemKind::Skill => last.to_string(),
397 }
398 }
399
400 pub fn from_host_relative(path: &Path, root: &Path) -> Result<Self, DestPathError> {
403 let relative = path
404 .strip_prefix(root)
405 .map_err(|_| DestPathError::ConversionFailed {
406 reason: format!("path {:?} is not under root {:?}", path, root),
407 })?;
408
409 let mut segments = Vec::new();
410 for component in relative.components() {
411 match component {
412 Component::Normal(seg) => segments.push(seg.to_string_lossy().into_owned()),
413 Component::CurDir => {}
414 Component::ParentDir => {
415 return Err(DestPathError::Escaping {
416 input: path.to_string_lossy().into_owned(),
417 });
418 }
419 Component::RootDir | Component::Prefix(_) => {
420 return Err(DestPathError::Absolute {
421 input: path.to_string_lossy().into_owned(),
422 });
423 }
424 }
425 }
426
427 if segments.is_empty() {
428 return Err(DestPathError::Empty);
429 }
430
431 Self::new(segments.join("/"))
432 }
433}
434
435impl From<&str> for DestPath {
436 fn from(value: &str) -> Self {
437 Self::new(value).expect("invalid destination path")
438 }
439}
440
441impl From<String> for DestPath {
442 fn from(value: String) -> Self {
443 Self::new(value).expect("invalid destination path")
444 }
445}
446
447impl AsRef<str> for DestPath {
448 fn as_ref(&self) -> &str {
449 &self.0
450 }
451}
452
453impl Borrow<str> for DestPath {
454 fn borrow(&self) -> &str {
455 &self.0
456 }
457}
458
459impl Hash for DestPath {
460 fn hash<H: Hasher>(&self, state: &mut H) {
461 self.0.hash(state);
462 }
463}
464
465impl fmt::Display for DestPath {
466 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
467 f.write_str(&self.0)
468 }
469}
470
471impl Serialize for DestPath {
472 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
473 self.0.serialize(serializer)
474 }
475}
476
477impl<'de> Deserialize<'de> for DestPath {
478 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
479 let value = String::deserialize(deserializer)?;
480 DestPath::new(value).map_err(serde::de::Error::custom)
481 }
482}
483
484#[derive(Debug, Clone)]
488pub struct MarsContext {
489 pub project_root: PathBuf,
491 pub managed_root: PathBuf,
493}
494
495#[cfg(test)]
496impl MarsContext {
497 pub fn for_test(project_root: PathBuf, managed_root: PathBuf) -> Self {
499 MarsContext {
500 project_root,
501 managed_root,
502 }
503 }
504}
505
506#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd, Serialize, Deserialize)]
508pub enum SourceId {
509 Git {
510 url: SourceUrl,
511 #[serde(default, skip_serializing_if = "Option::is_none")]
512 subpath: Option<SourceSubpath>,
513 },
514 Path {
515 canonical: PathBuf,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
517 subpath: Option<SourceSubpath>,
518 },
519}
520
521impl SourceId {
522 pub fn git(url: SourceUrl) -> Self {
523 Self::Git { url, subpath: None }
524 }
525
526 pub fn git_with_subpath(url: SourceUrl, subpath: Option<SourceSubpath>) -> Self {
527 Self::Git { url, subpath }
528 }
529
530 pub fn path(base: &Path, relative_or_absolute: &Path) -> std::io::Result<Self> {
531 Self::path_with_subpath(base, relative_or_absolute, None)
532 }
533
534 pub fn path_with_subpath(
535 base: &Path,
536 relative_or_absolute: &Path,
537 subpath: Option<SourceSubpath>,
538 ) -> std::io::Result<Self> {
539 let candidate = if relative_or_absolute.is_absolute() {
540 relative_or_absolute.to_path_buf()
541 } else {
542 base.join(relative_or_absolute)
543 };
544 let canonical = dunce::canonicalize(&candidate)?;
545 Ok(Self::Path { canonical, subpath })
546 }
547}
548
549impl fmt::Display for SourceId {
550 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
551 match self {
552 Self::Git { url, subpath } => {
553 write!(f, "git:{url}")?;
554 if let Some(subpath) = subpath {
555 write!(f, "@{subpath}")?;
556 }
557 Ok(())
558 }
559 Self::Path { canonical, subpath } => {
560 write!(f, "path:{}", canonical.display())?;
561 if let Some(subpath) = subpath {
562 write!(f, "@{subpath}")?;
563 }
564 Ok(())
565 }
566 }
567 }
568}
569
570#[derive(Debug, Clone, PartialEq, Eq)]
571pub struct RenameRule {
572 pub from: ItemName,
573 pub to: ItemName,
574}
575
576#[derive(Debug, Clone, Default, PartialEq, Eq)]
578pub struct RenameMap(Vec<RenameRule>);
579
580impl RenameMap {
581 pub fn new() -> Self {
582 Self(Vec::new())
583 }
584
585 pub fn insert(&mut self, from: ItemName, to: ItemName) {
586 if let Some(existing) = self.0.iter_mut().find(|r| r.from == from) {
587 existing.to = to;
588 return;
589 }
590 self.0.push(RenameRule { from, to });
591 }
592
593 pub fn push(&mut self, rule: RenameRule) {
594 self.insert(rule.from, rule.to);
595 }
596
597 pub fn get(&self, from: &str) -> Option<&ItemName> {
598 self.0.iter().find(|r| r.from == from).map(|r| &r.to)
599 }
600
601 pub fn iter(&self) -> impl Iterator<Item = &RenameRule> {
602 self.0.iter()
603 }
604
605 pub fn is_empty(&self) -> bool {
606 self.0.is_empty()
607 }
608
609 pub fn len(&self) -> usize {
610 self.0.len()
611 }
612}
613
614impl Serialize for RenameMap {
615 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
616 use serde::ser::SerializeMap;
617 let mut map = serializer.serialize_map(Some(self.0.len()))?;
618 for rule in &self.0 {
619 map.serialize_entry(rule.from.as_str(), rule.to.as_str())?;
620 }
621 map.end()
622 }
623}
624
625impl<'de> Deserialize<'de> for RenameMap {
626 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
627 let map = indexmap::IndexMap::<String, String>::deserialize(deserializer)?;
628 Ok(Self(
629 map.into_iter()
630 .map(|(from, to)| RenameRule {
631 from: ItemName::from(from),
632 to: ItemName::from(to),
633 })
634 .collect(),
635 ))
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use serde::{Deserialize, Serialize};
643 use std::path::PathBuf;
644
645 #[derive(Debug, Serialize, Deserialize, PartialEq)]
646 struct Wrapper<T> {
647 value: T,
648 }
649
650 #[test]
651 fn dest_path_roundtrip() {
652 let v = Wrapper {
653 value: DestPath::from("agents/coder.md"),
654 };
655 let s = toml::to_string(&v).unwrap();
656 let out: Wrapper<DestPath> = toml::from_str(&s).unwrap();
657 assert_eq!(v, out);
658 }
659
660 #[test]
661 fn rename_map_toml_roundtrip_compat() {
662 #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
663 struct RenameWrapper {
664 rename: RenameMap,
665 }
666
667 let input = r#"rename = { "coder" = "cool-coder" }"#;
668 let parsed: RenameWrapper = toml::from_str(input).unwrap();
669 assert_eq!(
670 parsed.rename.get("coder").map(|v| v.as_str()),
671 Some("cool-coder")
672 );
673
674 let serialized = toml::to_string(&parsed).unwrap();
675 let reparsed: RenameWrapper = toml::from_str(&serialized).unwrap();
676 assert_eq!(parsed, reparsed);
677 }
678
679 #[test]
680 fn source_subpath_normalizes_windows_and_unix_separators() {
681 let subpath = SourceSubpath::new(r"plugins\foo/bar\baz").unwrap();
682 assert_eq!(subpath.as_str(), "plugins/foo/bar/baz");
683 }
684
685 #[test]
686 fn source_subpath_and_dest_path_share_normalization_rules() {
687 let raw = r"./plugins\foo/bar\";
688 let subpath = SourceSubpath::new(raw).unwrap();
689 let dest = DestPath::new(raw).unwrap();
690
691 assert_eq!(subpath.as_str(), "plugins/foo/bar");
692 assert_eq!(dest.as_str(), "plugins/foo/bar");
693 assert_eq!(subpath.as_str(), dest.as_str());
694 }
695
696 #[test]
697 fn source_subpath_rejects_empty() {
698 let err = SourceSubpath::new("").unwrap_err();
699 assert_eq!(err, SourceSubpathError::Empty);
700 }
701
702 #[test]
703 fn source_subpath_rejects_absolute() {
704 let err = SourceSubpath::new("/abs/path").unwrap_err();
705 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
706 }
707
708 #[test]
709 fn source_subpath_rejects_root_only() {
710 let err = SourceSubpath::new("/").unwrap_err();
711 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
712 }
713
714 #[test]
715 fn source_subpath_rejects_windows_absolute() {
716 let err = SourceSubpath::new(r"C:\abs\path").unwrap_err();
717 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
718 }
719
720 #[test]
721 fn source_subpath_rejects_escape() {
722 let err = SourceSubpath::new("../escape").unwrap_err();
723 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
724 }
725
726 #[test]
727 fn source_subpath_accepts_nested_relative_path() {
728 let subpath = SourceSubpath::new("a/b/c").unwrap();
729 assert_eq!(subpath.as_str(), "a/b/c");
730 }
731
732 #[test]
733 fn source_subpath_accepts_plugins_foo() {
734 let subpath = SourceSubpath::new("plugins/foo").unwrap();
735 assert_eq!(subpath.as_str(), "plugins/foo");
736 }
737
738 #[test]
739 fn source_subpath_serializes_with_forward_slashes() {
740 #[derive(Debug, Serialize, Deserialize, PartialEq)]
741 struct SubpathWrapper {
742 subpath: SourceSubpath,
743 }
744
745 let wrapper = SubpathWrapper {
746 subpath: SourceSubpath::new(r"plugins\foo").unwrap(),
747 };
748 let toml = toml::to_string(&wrapper).unwrap();
749 assert!(toml.contains("subpath = \"plugins/foo\""));
750 }
751
752 #[test]
753 fn source_subpath_join_under_base() {
754 let base = PathBuf::from("/tmp/mars");
755 let subpath = SourceSubpath::new("plugins/foo").unwrap();
756 let joined = subpath.join_under(&base).unwrap();
757 assert_eq!(joined, base.join("plugins").join("foo"));
758 }
759
760 #[test]
761 fn source_subpath_join_under_rejects_escape_path() {
762 let escaped = SourceSubpath(String::from("../escape"));
763 let err = escaped.join_under(Path::new("/tmp/base")).unwrap_err();
764 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
765 }
766
767 #[test]
771 fn source_subpath_accepts_deeply_nested() {
772 let subpath = SourceSubpath::new("a/b/c/d/e").unwrap();
773 assert_eq!(subpath.as_str(), "a/b/c/d/e");
774 }
775
776 #[test]
778 fn source_subpath_rejects_windows_drive_forward_slash() {
779 let err = SourceSubpath::new("C:/foo").unwrap_err();
780 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
781 }
782
783 #[test]
785 fn source_subpath_rejects_current_dir_dot() {
786 let err = SourceSubpath::new(".").unwrap_err();
787 assert_eq!(err, SourceSubpathError::Empty);
788 }
789
790 #[test]
791 fn dest_path_normalizes_windows_and_unix_separators() {
792 let path = DestPath::new(r"agents\foo/bar\baz.md").unwrap();
793 assert_eq!(path.as_str(), "agents/foo/bar/baz.md");
794 }
795
796 #[test]
797 fn dest_path_rejects_empty() {
798 let err = DestPath::new("").unwrap_err();
799 assert_eq!(err, DestPathError::Empty);
800 }
801
802 #[test]
803 fn dest_path_rejects_absolute() {
804 let err = DestPath::new("/abs/path").unwrap_err();
805 assert!(matches!(err, DestPathError::Absolute { .. }));
806 }
807
808 #[test]
809 fn dest_path_rejects_root_only() {
810 let err = DestPath::new("/").unwrap_err();
811 assert!(matches!(err, DestPathError::Absolute { .. }));
812 }
813
814 #[test]
815 fn dest_path_rejects_windows_absolute() {
816 let err = DestPath::new(r"C:\abs\path").unwrap_err();
817 assert!(matches!(err, DestPathError::Absolute { .. }));
818 }
819
820 #[test]
821 fn dest_path_rejects_windows_drive_relative() {
822 let err = DestPath::new("C:relative").unwrap_err();
823 assert!(matches!(err, DestPathError::Absolute { .. }));
824 }
825
826 #[test]
827 fn dest_path_rejects_escape() {
828 let err = DestPath::new("../escape").unwrap_err();
829 assert!(matches!(err, DestPathError::Escaping { .. }));
830 }
831
832 #[test]
833 fn dest_path_normalizes_trailing_slash() {
834 let path = DestPath::new("skills/planning/").unwrap();
835 assert_eq!(path.as_str(), "skills/planning");
836 }
837
838 #[test]
839 fn dest_path_normalizes_leading_dot_slash() {
840 let path = DestPath::new("./skills/planning").unwrap();
841 assert_eq!(path.as_str(), "skills/planning");
842 }
843
844 #[test]
845 fn dest_path_item_name_extracts_agent_leaf() {
846 let path = DestPath::new("agents/coder.md").unwrap();
847 assert_eq!(path.item_name(ItemKind::Agent), "coder");
848 }
849
850 #[test]
851 fn dest_path_item_name_extracts_skill_leaf() {
852 let path = DestPath::new("skills/planning").unwrap();
853 assert_eq!(path.item_name(ItemKind::Skill), "planning");
854 }
855
856 #[test]
857 fn dest_path_item_name_extracts_nested_agent_leaf() {
858 let path = DestPath::new("agents/sub/deep.md").unwrap();
859 assert_eq!(path.item_name(ItemKind::Agent), "deep");
860 }
861
862 #[test]
863 fn dest_path_item_name_handles_no_slash_edge_case() {
864 let path = DestPath::new("solo.md").unwrap();
865 assert_eq!(path.item_name(ItemKind::Agent), "solo");
866 }
867
868 #[test]
871 fn source_subpath_rejects_mid_path_double_parent_escape() {
872 let err = SourceSubpath::new("a/../../escape").unwrap_err();
873 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
874 }
875
876 #[test]
879 fn source_subpath_rejects_harmless_parent_in_middle() {
880 let err = SourceSubpath::new("a/b/../c").unwrap_err();
881 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
882 }
883
884 #[test]
886 fn source_subpath_normalizes_trailing_slash() {
887 let subpath = SourceSubpath::new("plugins/foo/").unwrap();
888 assert_eq!(subpath.as_str(), "plugins/foo");
889 }
890
891 #[test]
893 fn source_subpath_normalizes_leading_dot_slash() {
894 let subpath = SourceSubpath::new("./plugins/foo").unwrap();
895 assert_eq!(subpath.as_str(), "plugins/foo");
896 }
897
898 #[test]
900 fn source_subpath_join_under_base_with_trailing_slash() {
901 let base = PathBuf::from("/tmp/mars/");
902 let subpath = SourceSubpath::new("plugins/foo").unwrap();
903 let joined = subpath.join_under(&base).unwrap();
904 assert_eq!(joined, PathBuf::from("/tmp/mars/plugins/foo"));
906 }
907
908 #[test]
910 fn locked_source_json_roundtrip_without_subpath() {
911 let json = r#"{"url":"https://github.com/org/base.git"}"#;
912 let parsed: crate::lock::LockedSource = serde_json::from_str(json).unwrap();
913 assert!(parsed.subpath.is_none());
914 }
915
916 #[test]
918 fn locked_source_json_roundtrip_with_subpath() {
919 let source = crate::lock::LockedSource {
920 url: Some(SourceUrl::from("https://github.com/org/base.git")),
921 path: None,
922 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
923 version: None,
924 commit: None,
925 tree_hash: None,
926 };
927 let json = serde_json::to_string(&source).unwrap();
928 assert!(json.contains("\"subpath\":\"plugins/foo\""));
929 let reparsed: crate::lock::LockedSource = serde_json::from_str(&json).unwrap();
930 assert_eq!(
931 reparsed.subpath.as_ref().map(SourceSubpath::as_str),
932 Some("plugins/foo")
933 );
934 }
935
936 #[test]
938 fn locked_source_toml_missing_subpath_field_is_none() {
939 let toml_str = r#"
940version = 1
941
942[dependencies.dep]
943url = "https://github.com/org/dep.git"
944commit = "deadbeef"
945"#;
946 let lock: crate::lock::LockFile = toml::from_str(toml_str).unwrap();
947 assert!(lock.dependencies["dep"].subpath.is_none());
948 }
949
950 #[test]
952 fn locked_source_toml_subpath_serializes_alongside_other_fields() {
953 let source = crate::lock::LockedSource {
954 url: Some(SourceUrl::from("https://github.com/org/base.git")),
955 path: None,
956 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
957 version: Some("v1.0.0".to_string()),
958 commit: Some(CommitHash::from("abc123")),
959 tree_hash: None,
960 };
961 #[derive(Serialize)]
962 struct Wrapper {
963 source: crate::lock::LockedSource,
964 }
965 let serialized = toml::to_string(&Wrapper { source }).unwrap();
966 assert!(serialized.contains("subpath = \"plugins/foo\""));
967 assert!(serialized.contains("url = "));
968 assert!(serialized.contains("commit = "));
969 }
970
971 #[test]
972 fn lock_roundtrip_with_and_without_subpath() {
973 let old_lock = r#"
974version = 1
975
976[dependencies.base]
977url = "https://github.com/org/base.git"
978"#;
979 let parsed_old: crate::lock::LockFile = toml::from_str(old_lock).unwrap();
980 assert!(parsed_old.dependencies["base"].subpath.is_none());
981
982 let lock = crate::lock::LockFile {
983 version: 1,
984 dependencies: indexmap::IndexMap::from([(
985 SourceName::from("base"),
986 crate::lock::LockedSource {
987 url: Some(SourceUrl::from("https://github.com/org/base.git")),
988 path: None,
989 subpath: Some(SourceSubpath::new(r"plugins\foo").unwrap()),
990 version: Some("v1.2.3".to_string()),
991 commit: Some(CommitHash::from("abc123")),
992 tree_hash: None,
993 },
994 )]),
995 items: indexmap::IndexMap::new(),
996 };
997 let serialized = toml::to_string_pretty(&lock).unwrap();
998 assert!(serialized.contains("subpath = \"plugins/foo\""));
999 let reparsed: crate::lock::LockFile = toml::from_str(&serialized).unwrap();
1000 assert_eq!(
1001 reparsed.dependencies["base"]
1002 .subpath
1003 .as_ref()
1004 .map(SourceSubpath::as_str),
1005 Some("plugins/foo")
1006 );
1007 }
1008
1009 #[test]
1010 fn config_roundtrip_preserves_subpath() {
1011 let config = r#"
1012[dependencies.base]
1013url = "https://github.com/org/base.git"
1014subpath = "plugins\\foo"
1015"#;
1016 let parsed: crate::config::Config = toml::from_str(config).unwrap();
1017 assert_eq!(
1018 parsed.dependencies["base"]
1019 .subpath
1020 .as_ref()
1021 .map(SourceSubpath::as_str),
1022 Some("plugins/foo")
1023 );
1024
1025 let serialized = toml::to_string(&parsed).unwrap();
1026 assert!(serialized.contains("subpath = \"plugins/foo\""));
1027 let reparsed: crate::config::Config = toml::from_str(&serialized).unwrap();
1028 assert_eq!(
1029 reparsed.dependencies["base"]
1030 .subpath
1031 .as_ref()
1032 .map(SourceSubpath::as_str),
1033 Some("plugins/foo")
1034 );
1035 }
1036
1037 #[test]
1038 fn source_id_git_same_url_same_subpath_are_equal_and_hash_equal() {
1039 let a = SourceId::git_with_subpath(
1040 SourceUrl::from("https://example.com/repo.git"),
1041 Some(SourceSubpath::new("plugins/foo").unwrap()),
1042 );
1043 let b = SourceId::git_with_subpath(
1044 SourceUrl::from("https://example.com/repo.git"),
1045 Some(SourceSubpath::new("plugins/foo").unwrap()),
1046 );
1047
1048 assert_eq!(a, b);
1049
1050 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1051 a.hash(&mut hasher_a);
1052 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1053 b.hash(&mut hasher_b);
1054 assert_eq!(hasher_a.finish(), hasher_b.finish());
1055 }
1056
1057 #[test]
1058 fn source_id_git_same_url_different_subpaths_are_distinct() {
1059 let a = SourceId::git_with_subpath(
1060 SourceUrl::from("https://example.com/repo.git"),
1061 Some(SourceSubpath::new("plugins/foo").unwrap()),
1062 );
1063 let b = SourceId::git_with_subpath(
1064 SourceUrl::from("https://example.com/repo.git"),
1065 Some(SourceSubpath::new("plugins/bar").unwrap()),
1066 );
1067
1068 assert_ne!(a, b);
1069
1070 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1071 a.hash(&mut hasher_a);
1072 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1073 b.hash(&mut hasher_b);
1074 assert_ne!(hasher_a.finish(), hasher_b.finish());
1075 }
1076
1077 #[test]
1083 fn source_id_path_none_and_some_subpath_hash_distinctly() {
1084 let canonical = PathBuf::from("/tmp/my-repo");
1085 let a = SourceId::Path {
1086 canonical: canonical.clone(),
1087 subpath: None,
1088 };
1089 let b = SourceId::Path {
1090 canonical: canonical.clone(),
1091 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1092 };
1093
1094 assert_ne!(a, b);
1095
1096 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1097 a.hash(&mut hasher_a);
1098 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1099 b.hash(&mut hasher_b);
1100 assert_ne!(hasher_a.finish(), hasher_b.finish());
1101 }
1102
1103 #[test]
1106 fn source_id_path_same_canonical_same_subpath_are_equal() {
1107 let canonical = PathBuf::from("/tmp/my-repo");
1108 let a = SourceId::Path {
1109 canonical: canonical.clone(),
1110 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1111 };
1112 let b = SourceId::Path {
1113 canonical: canonical.clone(),
1114 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1115 };
1116
1117 assert_eq!(a, b);
1118
1119 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1120 a.hash(&mut hasher_a);
1121 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1122 b.hash(&mut hasher_b);
1123 assert_eq!(hasher_a.finish(), hasher_b.finish());
1124 }
1125
1126 #[test]
1129 fn source_id_path_same_canonical_different_subpaths_are_distinct() {
1130 let canonical = PathBuf::from("/tmp/my-repo");
1131 let a = SourceId::Path {
1132 canonical: canonical.clone(),
1133 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1134 };
1135 let b = SourceId::Path {
1136 canonical: canonical.clone(),
1137 subpath: Some(SourceSubpath::new("plugins/bar").unwrap()),
1138 };
1139
1140 assert_ne!(a, b);
1141
1142 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1143 a.hash(&mut hasher_a);
1144 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1145 b.hash(&mut hasher_b);
1146 assert_ne!(hasher_a.finish(), hasher_b.finish());
1147 }
1148
1149 #[test]
1155 fn lock_write_and_load_roundtrip_preserves_subpath() {
1156 use crate::lock::{LockFile, LockedSource};
1157 use tempfile::TempDir;
1158
1159 let dir = TempDir::new().unwrap();
1160 let lock = LockFile {
1161 version: 1,
1162 dependencies: indexmap::IndexMap::from([(
1163 SourceName::from("dep"),
1164 LockedSource {
1165 url: Some(SourceUrl::from("https://github.com/org/repo.git")),
1166 path: None,
1167 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1168 version: Some("v1.2.3".to_string()),
1169 commit: Some(CommitHash::from("deadbeef")),
1170 tree_hash: None,
1171 },
1172 )]),
1173 items: indexmap::IndexMap::new(),
1174 };
1175
1176 crate::lock::write(dir.path(), &lock).unwrap();
1177 let loaded = crate::lock::load(dir.path()).unwrap();
1178
1179 assert_eq!(
1180 loaded.dependencies["dep"]
1181 .subpath
1182 .as_ref()
1183 .map(SourceSubpath::as_str),
1184 Some("plugins/foo")
1185 );
1186 assert_eq!(
1187 loaded.dependencies["dep"].url.as_deref(),
1188 Some("https://github.com/org/repo.git")
1189 );
1190 assert_eq!(
1191 loaded.dependencies["dep"].version.as_deref(),
1192 Some("v1.2.3")
1193 );
1194 }
1195
1196 #[test]
1202 fn effective_dependency_subpath_preserved_through_merge() {
1203 use crate::config::{Config, merge};
1204
1205 let toml_str = r#"
1206[dependencies.dep]
1207url = "https://github.com/org/repo.git"
1208subpath = "plugins/foo"
1209"#;
1210 let config: Config = toml::from_str(toml_str).unwrap();
1211 let effective = merge(config, crate::config::LocalConfig::default()).unwrap();
1212 assert_eq!(
1213 effective.dependencies["dep"]
1214 .subpath
1215 .as_ref()
1216 .map(SourceSubpath::as_str),
1217 Some("plugins/foo")
1218 );
1219 assert!(matches!(
1221 &effective.dependencies["dep"].id,
1222 SourceId::Git { subpath: Some(sp), .. } if sp.as_str() == "plugins/foo"
1223 ));
1224 }
1225}