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 Hook,
305 McpServer,
306 BootstrapDoc,
307}
308
309impl fmt::Display for ItemKind {
310 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
311 match self {
312 ItemKind::Agent => write!(f, "agent"),
313 ItemKind::Skill => write!(f, "skill"),
314 ItemKind::Hook => write!(f, "hook"),
315 ItemKind::McpServer => write!(f, "mcp-server"),
316 ItemKind::BootstrapDoc => write!(f, "bootstrap-doc"),
317 }
318 }
319}
320
321#[derive(Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
326pub struct ItemId {
327 pub kind: ItemKind,
328 pub name: ItemName,
329}
330
331impl fmt::Display for ItemId {
332 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
333 write!(f, "{}/{}", self.kind, self.name)
334 }
335}
336
337#[derive(Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
340pub struct DestPath(String);
341
342impl DestPath {
343 pub fn new(value: impl AsRef<str>) -> Result<Self, DestPathError> {
345 let raw = value.as_ref();
346 if raw.is_empty() {
347 return Err(DestPathError::Empty);
348 }
349
350 let normalized_separators = raw.replace('\\', "/");
351 if is_windows_absolute(&normalized_separators)
352 || is_windows_drive_relative(&normalized_separators)
353 {
354 return Err(DestPathError::Absolute {
355 input: raw.to_string(),
356 });
357 }
358
359 let normalized = normalize_relative_coordinate(raw).map_err(|err| match err {
360 NormalizeError::Empty => DestPathError::Empty,
361 NormalizeError::Absolute => DestPathError::Absolute {
362 input: raw.to_string(),
363 },
364 NormalizeError::Escaping => DestPathError::Escaping {
365 input: raw.to_string(),
366 },
367 })?;
368
369 Ok(Self(normalized))
370 }
371
372 pub fn as_str(&self) -> &str {
374 &self.0
375 }
376
377 pub fn into_inner(self) -> String {
379 self.0
380 }
381
382 pub fn resolve(&self, root: &Path) -> PathBuf {
384 let mut result = root.to_path_buf();
385 for component in self.components() {
386 result.push(component);
387 }
388 result
389 }
390
391 pub fn components(&self) -> impl Iterator<Item = &str> {
393 self.0.split('/')
394 }
395
396 pub fn item_name(&self, kind: ItemKind) -> String {
401 match kind {
402 ItemKind::BootstrapDoc => self
403 .0
404 .strip_suffix("/BOOTSTRAP.md")
405 .and_then(|path| path.rsplit('/').next())
406 .unwrap_or_else(|| self.0.rsplit('/').next().unwrap_or(""))
407 .to_string(),
408 _ => {
409 let last = self.0.rsplit('/').next().unwrap_or("");
410 match kind {
411 ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
412 ItemKind::Skill | ItemKind::Hook | ItemKind::McpServer => last.to_string(),
413 ItemKind::BootstrapDoc => unreachable!("handled above"),
414 }
415 }
416 }
417 }
418
419 pub fn from_host_relative(path: &Path, root: &Path) -> Result<Self, DestPathError> {
422 let relative = path
423 .strip_prefix(root)
424 .map_err(|_| DestPathError::ConversionFailed {
425 reason: format!("path {:?} is not under root {:?}", path, root),
426 })?;
427
428 let mut segments = Vec::new();
429 for component in relative.components() {
430 match component {
431 Component::Normal(seg) => segments.push(seg.to_string_lossy().into_owned()),
432 Component::CurDir => {}
433 Component::ParentDir => {
434 return Err(DestPathError::Escaping {
435 input: path.to_string_lossy().into_owned(),
436 });
437 }
438 Component::RootDir | Component::Prefix(_) => {
439 return Err(DestPathError::Absolute {
440 input: path.to_string_lossy().into_owned(),
441 });
442 }
443 }
444 }
445
446 if segments.is_empty() {
447 return Err(DestPathError::Empty);
448 }
449
450 Self::new(segments.join("/"))
451 }
452}
453
454impl From<&str> for DestPath {
455 fn from(value: &str) -> Self {
456 Self::new(value).expect("invalid destination path")
457 }
458}
459
460impl From<String> for DestPath {
461 fn from(value: String) -> Self {
462 Self::new(value).expect("invalid destination path")
463 }
464}
465
466impl AsRef<str> for DestPath {
467 fn as_ref(&self) -> &str {
468 &self.0
469 }
470}
471
472impl Borrow<str> for DestPath {
473 fn borrow(&self) -> &str {
474 &self.0
475 }
476}
477
478impl Hash for DestPath {
479 fn hash<H: Hasher>(&self, state: &mut H) {
480 self.0.hash(state);
481 }
482}
483
484impl fmt::Display for DestPath {
485 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
486 f.write_str(&self.0)
487 }
488}
489
490impl Serialize for DestPath {
491 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
492 self.0.serialize(serializer)
493 }
494}
495
496impl<'de> Deserialize<'de> for DestPath {
497 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
498 let value = String::deserialize(deserializer)?;
499 DestPath::new(value).map_err(serde::de::Error::custom)
500 }
501}
502
503#[derive(Debug, Clone)]
507pub struct MarsContext {
508 pub project_root: PathBuf,
510 pub managed_root: PathBuf,
512 pub meridian_managed: bool,
517}
518
519#[cfg(test)]
520impl MarsContext {
521 pub fn for_test(project_root: PathBuf, managed_root: PathBuf) -> Self {
523 MarsContext {
524 project_root,
525 managed_root,
526 meridian_managed: meridian_managed_from_env(),
527 }
528 }
529}
530
531pub fn meridian_managed_from_env() -> bool {
532 std::env::var("MERIDIAN_MANAGED").is_ok_and(|value| value == "1")
533}
534
535#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd, Serialize, Deserialize)]
537pub enum SourceId {
538 Git {
539 url: SourceUrl,
540 #[serde(default, skip_serializing_if = "Option::is_none")]
541 subpath: Option<SourceSubpath>,
542 },
543 Path {
544 canonical: PathBuf,
545 #[serde(default, skip_serializing_if = "Option::is_none")]
546 subpath: Option<SourceSubpath>,
547 },
548}
549
550impl SourceId {
551 pub fn git(url: SourceUrl) -> Self {
552 Self::Git { url, subpath: None }
553 }
554
555 pub fn git_with_subpath(url: SourceUrl, subpath: Option<SourceSubpath>) -> Self {
556 Self::Git { url, subpath }
557 }
558
559 pub fn path(base: &Path, relative_or_absolute: &Path) -> std::io::Result<Self> {
560 Self::path_with_subpath(base, relative_or_absolute, None)
561 }
562
563 pub fn path_with_subpath(
564 base: &Path,
565 relative_or_absolute: &Path,
566 subpath: Option<SourceSubpath>,
567 ) -> std::io::Result<Self> {
568 let candidate = if relative_or_absolute.is_absolute() {
569 relative_or_absolute.to_path_buf()
570 } else {
571 base.join(relative_or_absolute)
572 };
573 let canonical = dunce::canonicalize(&candidate)?;
574 Ok(Self::Path { canonical, subpath })
575 }
576}
577
578impl fmt::Display for SourceId {
579 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
580 match self {
581 Self::Git { url, subpath } => {
582 write!(f, "git:{url}")?;
583 if let Some(subpath) = subpath {
584 write!(f, "@{subpath}")?;
585 }
586 Ok(())
587 }
588 Self::Path { canonical, subpath } => {
589 write!(f, "path:{}", canonical.display())?;
590 if let Some(subpath) = subpath {
591 write!(f, "@{subpath}")?;
592 }
593 Ok(())
594 }
595 }
596 }
597}
598
599#[derive(Debug, Clone, PartialEq, Eq)]
600pub struct RenameRule {
601 pub from: ItemName,
602 pub to: ItemName,
603}
604
605#[derive(Debug, Clone, Default, PartialEq, Eq)]
607pub struct RenameMap(Vec<RenameRule>);
608
609impl RenameMap {
610 pub fn new() -> Self {
611 Self(Vec::new())
612 }
613
614 pub fn insert(&mut self, from: ItemName, to: ItemName) {
615 if let Some(existing) = self.0.iter_mut().find(|r| r.from == from) {
616 existing.to = to;
617 return;
618 }
619 self.0.push(RenameRule { from, to });
620 }
621
622 pub fn push(&mut self, rule: RenameRule) {
623 self.insert(rule.from, rule.to);
624 }
625
626 pub fn get(&self, from: &str) -> Option<&ItemName> {
627 self.0.iter().find(|r| r.from == from).map(|r| &r.to)
628 }
629
630 pub fn iter(&self) -> impl Iterator<Item = &RenameRule> {
631 self.0.iter()
632 }
633
634 pub fn is_empty(&self) -> bool {
635 self.0.is_empty()
636 }
637
638 pub fn len(&self) -> usize {
639 self.0.len()
640 }
641}
642
643impl Serialize for RenameMap {
644 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
645 use serde::ser::SerializeMap;
646 let mut map = serializer.serialize_map(Some(self.0.len()))?;
647 for rule in &self.0 {
648 map.serialize_entry(rule.from.as_str(), rule.to.as_str())?;
649 }
650 map.end()
651 }
652}
653
654impl<'de> Deserialize<'de> for RenameMap {
655 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
656 let map = indexmap::IndexMap::<String, String>::deserialize(deserializer)?;
657 Ok(Self(
658 map.into_iter()
659 .map(|(from, to)| RenameRule {
660 from: ItemName::from(from),
661 to: ItemName::from(to),
662 })
663 .collect(),
664 ))
665 }
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use serde::{Deserialize, Serialize};
672 use std::path::PathBuf;
673
674 #[derive(Debug, Serialize, Deserialize, PartialEq)]
675 struct Wrapper<T> {
676 value: T,
677 }
678
679 #[test]
680 fn dest_path_roundtrip() {
681 let v = Wrapper {
682 value: DestPath::from("agents/coder.md"),
683 };
684 let s = toml::to_string(&v).unwrap();
685 let out: Wrapper<DestPath> = toml::from_str(&s).unwrap();
686 assert_eq!(v, out);
687 }
688
689 #[test]
690 fn rename_map_toml_roundtrip_compat() {
691 #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
692 struct RenameWrapper {
693 rename: RenameMap,
694 }
695
696 let input = r#"rename = { "coder" = "cool-coder" }"#;
697 let parsed: RenameWrapper = toml::from_str(input).unwrap();
698 assert_eq!(
699 parsed.rename.get("coder").map(|v| v.as_str()),
700 Some("cool-coder")
701 );
702
703 let serialized = toml::to_string(&parsed).unwrap();
704 let reparsed: RenameWrapper = toml::from_str(&serialized).unwrap();
705 assert_eq!(parsed, reparsed);
706 }
707
708 #[test]
709 fn source_subpath_normalizes_windows_and_unix_separators() {
710 let subpath = SourceSubpath::new(r"plugins\foo/bar\baz").unwrap();
711 assert_eq!(subpath.as_str(), "plugins/foo/bar/baz");
712 }
713
714 #[test]
715 fn source_subpath_and_dest_path_share_normalization_rules() {
716 let raw = r"./plugins\foo/bar\";
717 let subpath = SourceSubpath::new(raw).unwrap();
718 let dest = DestPath::new(raw).unwrap();
719
720 assert_eq!(subpath.as_str(), "plugins/foo/bar");
721 assert_eq!(dest.as_str(), "plugins/foo/bar");
722 assert_eq!(subpath.as_str(), dest.as_str());
723 }
724
725 #[test]
726 fn source_subpath_rejects_empty() {
727 let err = SourceSubpath::new("").unwrap_err();
728 assert_eq!(err, SourceSubpathError::Empty);
729 }
730
731 #[test]
732 fn source_subpath_rejects_absolute() {
733 let err = SourceSubpath::new("/abs/path").unwrap_err();
734 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
735 }
736
737 #[test]
738 fn source_subpath_rejects_root_only() {
739 let err = SourceSubpath::new("/").unwrap_err();
740 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
741 }
742
743 #[test]
744 fn source_subpath_rejects_windows_absolute() {
745 let err = SourceSubpath::new(r"C:\abs\path").unwrap_err();
746 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
747 }
748
749 #[test]
750 fn source_subpath_rejects_escape() {
751 let err = SourceSubpath::new("../escape").unwrap_err();
752 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
753 }
754
755 #[test]
756 fn source_subpath_accepts_nested_relative_path() {
757 let subpath = SourceSubpath::new("a/b/c").unwrap();
758 assert_eq!(subpath.as_str(), "a/b/c");
759 }
760
761 #[test]
762 fn source_subpath_accepts_plugins_foo() {
763 let subpath = SourceSubpath::new("plugins/foo").unwrap();
764 assert_eq!(subpath.as_str(), "plugins/foo");
765 }
766
767 #[test]
768 fn source_subpath_serializes_with_forward_slashes() {
769 #[derive(Debug, Serialize, Deserialize, PartialEq)]
770 struct SubpathWrapper {
771 subpath: SourceSubpath,
772 }
773
774 let wrapper = SubpathWrapper {
775 subpath: SourceSubpath::new(r"plugins\foo").unwrap(),
776 };
777 let toml = toml::to_string(&wrapper).unwrap();
778 assert!(toml.contains("subpath = \"plugins/foo\""));
779 }
780
781 #[test]
782 fn source_subpath_join_under_base() {
783 let base = PathBuf::from("/tmp/mars");
784 let subpath = SourceSubpath::new("plugins/foo").unwrap();
785 let joined = subpath.join_under(&base).unwrap();
786 assert_eq!(joined, base.join("plugins").join("foo"));
787 }
788
789 #[test]
790 fn source_subpath_join_under_rejects_escape_path() {
791 let escaped = SourceSubpath(String::from("../escape"));
792 let err = escaped.join_under(Path::new("/tmp/base")).unwrap_err();
793 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
794 }
795
796 #[test]
800 fn source_subpath_accepts_deeply_nested() {
801 let subpath = SourceSubpath::new("a/b/c/d/e").unwrap();
802 assert_eq!(subpath.as_str(), "a/b/c/d/e");
803 }
804
805 #[test]
807 fn source_subpath_rejects_windows_drive_forward_slash() {
808 let err = SourceSubpath::new("C:/foo").unwrap_err();
809 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
810 }
811
812 #[test]
814 fn source_subpath_rejects_current_dir_dot() {
815 let err = SourceSubpath::new(".").unwrap_err();
816 assert_eq!(err, SourceSubpathError::Empty);
817 }
818
819 #[test]
820 fn dest_path_normalizes_windows_and_unix_separators() {
821 let path = DestPath::new(r"agents\foo/bar\baz.md").unwrap();
822 assert_eq!(path.as_str(), "agents/foo/bar/baz.md");
823 }
824
825 #[test]
826 fn dest_path_rejects_empty() {
827 let err = DestPath::new("").unwrap_err();
828 assert_eq!(err, DestPathError::Empty);
829 }
830
831 #[test]
832 fn dest_path_rejects_absolute() {
833 let err = DestPath::new("/abs/path").unwrap_err();
834 assert!(matches!(err, DestPathError::Absolute { .. }));
835 }
836
837 #[test]
838 fn dest_path_rejects_root_only() {
839 let err = DestPath::new("/").unwrap_err();
840 assert!(matches!(err, DestPathError::Absolute { .. }));
841 }
842
843 #[test]
844 fn dest_path_rejects_windows_absolute() {
845 let err = DestPath::new(r"C:\abs\path").unwrap_err();
846 assert!(matches!(err, DestPathError::Absolute { .. }));
847 }
848
849 #[test]
850 fn dest_path_rejects_windows_drive_relative() {
851 let err = DestPath::new("C:relative").unwrap_err();
852 assert!(matches!(err, DestPathError::Absolute { .. }));
853 }
854
855 #[test]
856 fn dest_path_rejects_escape() {
857 let err = DestPath::new("../escape").unwrap_err();
858 assert!(matches!(err, DestPathError::Escaping { .. }));
859 }
860
861 #[test]
862 fn dest_path_normalizes_trailing_slash() {
863 let path = DestPath::new("skills/planning/").unwrap();
864 assert_eq!(path.as_str(), "skills/planning");
865 }
866
867 #[test]
868 fn dest_path_normalizes_leading_dot_slash() {
869 let path = DestPath::new("./skills/planning").unwrap();
870 assert_eq!(path.as_str(), "skills/planning");
871 }
872
873 #[test]
874 fn dest_path_item_name_extracts_agent_leaf() {
875 let path = DestPath::new("agents/coder.md").unwrap();
876 assert_eq!(path.item_name(ItemKind::Agent), "coder");
877 }
878
879 #[test]
880 fn dest_path_item_name_extracts_skill_leaf() {
881 let path = DestPath::new("skills/planning").unwrap();
882 assert_eq!(path.item_name(ItemKind::Skill), "planning");
883 }
884
885 #[test]
886 fn dest_path_item_name_extracts_bootstrap_doc_container() {
887 let path = DestPath::new("bootstrap/global-auth/BOOTSTRAP.md").unwrap();
888 assert_eq!(path.item_name(ItemKind::BootstrapDoc), "global-auth");
889 }
890
891 #[test]
892 fn dest_path_item_name_extracts_nested_agent_leaf() {
893 let path = DestPath::new("agents/sub/deep.md").unwrap();
894 assert_eq!(path.item_name(ItemKind::Agent), "deep");
895 }
896
897 #[test]
898 fn dest_path_item_name_handles_no_slash_edge_case() {
899 let path = DestPath::new("solo.md").unwrap();
900 assert_eq!(path.item_name(ItemKind::Agent), "solo");
901 }
902
903 #[test]
906 fn source_subpath_rejects_mid_path_double_parent_escape() {
907 let err = SourceSubpath::new("a/../../escape").unwrap_err();
908 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
909 }
910
911 #[test]
914 fn source_subpath_rejects_harmless_parent_in_middle() {
915 let err = SourceSubpath::new("a/b/../c").unwrap_err();
916 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
917 }
918
919 #[test]
921 fn source_subpath_normalizes_trailing_slash() {
922 let subpath = SourceSubpath::new("plugins/foo/").unwrap();
923 assert_eq!(subpath.as_str(), "plugins/foo");
924 }
925
926 #[test]
928 fn source_subpath_normalizes_leading_dot_slash() {
929 let subpath = SourceSubpath::new("./plugins/foo").unwrap();
930 assert_eq!(subpath.as_str(), "plugins/foo");
931 }
932
933 #[test]
935 fn source_subpath_join_under_base_with_trailing_slash() {
936 let base = PathBuf::from("/tmp/mars/");
937 let subpath = SourceSubpath::new("plugins/foo").unwrap();
938 let joined = subpath.join_under(&base).unwrap();
939 assert_eq!(joined, PathBuf::from("/tmp/mars/plugins/foo"));
941 }
942
943 #[test]
945 fn locked_source_json_roundtrip_without_subpath() {
946 let json = r#"{"url":"https://github.com/org/base.git"}"#;
947 let parsed: crate::lock::LockedSource = serde_json::from_str(json).unwrap();
948 assert!(parsed.subpath.is_none());
949 }
950
951 #[test]
953 fn locked_source_json_roundtrip_with_subpath() {
954 let source = crate::lock::LockedSource {
955 url: Some(SourceUrl::from("https://github.com/org/base.git")),
956 path: None,
957 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
958 version: None,
959 commit: None,
960 tree_hash: None,
961 };
962 let json = serde_json::to_string(&source).unwrap();
963 assert!(json.contains("\"subpath\":\"plugins/foo\""));
964 let reparsed: crate::lock::LockedSource = serde_json::from_str(&json).unwrap();
965 assert_eq!(
966 reparsed.subpath.as_ref().map(SourceSubpath::as_str),
967 Some("plugins/foo")
968 );
969 }
970
971 #[test]
973 fn locked_source_toml_missing_subpath_field_is_none() {
974 let toml_str = r#"
975version = 1
976
977[dependencies.dep]
978url = "https://github.com/org/dep.git"
979commit = "deadbeef"
980"#;
981 let lock: crate::lock::LockFile = toml::from_str(toml_str).unwrap();
982 assert!(lock.dependencies["dep"].subpath.is_none());
983 }
984
985 #[test]
987 fn locked_source_toml_subpath_serializes_alongside_other_fields() {
988 let source = crate::lock::LockedSource {
989 url: Some(SourceUrl::from("https://github.com/org/base.git")),
990 path: None,
991 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
992 version: Some("v1.0.0".to_string()),
993 commit: Some(CommitHash::from("abc123")),
994 tree_hash: None,
995 };
996 #[derive(Serialize)]
997 struct Wrapper {
998 source: crate::lock::LockedSource,
999 }
1000 let serialized = toml::to_string(&Wrapper { source }).unwrap();
1001 assert!(serialized.contains("subpath = \"plugins/foo\""));
1002 assert!(serialized.contains("url = "));
1003 assert!(serialized.contains("commit = "));
1004 }
1005
1006 #[test]
1007 fn lock_roundtrip_with_and_without_subpath() {
1008 let old_lock = r#"
1009version = 1
1010
1011[dependencies.base]
1012url = "https://github.com/org/base.git"
1013"#;
1014 let parsed_old: crate::lock::LockFile = toml::from_str(old_lock).unwrap();
1015 assert!(parsed_old.dependencies["base"].subpath.is_none());
1016
1017 let lock = crate::lock::LockFile {
1018 version: 1,
1019 dependencies: indexmap::IndexMap::from([(
1020 SourceName::from("base"),
1021 crate::lock::LockedSource {
1022 url: Some(SourceUrl::from("https://github.com/org/base.git")),
1023 path: None,
1024 subpath: Some(SourceSubpath::new(r"plugins\foo").unwrap()),
1025 version: Some("v1.2.3".to_string()),
1026 commit: Some(CommitHash::from("abc123")),
1027 tree_hash: None,
1028 },
1029 )]),
1030 items: indexmap::IndexMap::new(),
1031 config_entries: std::collections::BTreeMap::new(),
1032 };
1033 let serialized = toml::to_string_pretty(&lock).unwrap();
1034 assert!(serialized.contains("subpath = \"plugins/foo\""));
1035 let reparsed: crate::lock::LockFile = toml::from_str(&serialized).unwrap();
1036 assert_eq!(
1037 reparsed.dependencies["base"]
1038 .subpath
1039 .as_ref()
1040 .map(SourceSubpath::as_str),
1041 Some("plugins/foo")
1042 );
1043 }
1044
1045 #[test]
1046 fn config_roundtrip_preserves_subpath() {
1047 let config = r#"
1048[dependencies.base]
1049url = "https://github.com/org/base.git"
1050subpath = "plugins\\foo"
1051"#;
1052 let parsed: crate::config::Config = toml::from_str(config).unwrap();
1053 assert_eq!(
1054 parsed.dependencies["base"]
1055 .subpath
1056 .as_ref()
1057 .map(SourceSubpath::as_str),
1058 Some("plugins/foo")
1059 );
1060
1061 let serialized = toml::to_string(&parsed).unwrap();
1062 assert!(serialized.contains("subpath = \"plugins/foo\""));
1063 let reparsed: crate::config::Config = toml::from_str(&serialized).unwrap();
1064 assert_eq!(
1065 reparsed.dependencies["base"]
1066 .subpath
1067 .as_ref()
1068 .map(SourceSubpath::as_str),
1069 Some("plugins/foo")
1070 );
1071 }
1072
1073 #[test]
1074 fn source_id_git_same_url_same_subpath_are_equal_and_hash_equal() {
1075 let a = SourceId::git_with_subpath(
1076 SourceUrl::from("https://example.com/repo.git"),
1077 Some(SourceSubpath::new("plugins/foo").unwrap()),
1078 );
1079 let b = SourceId::git_with_subpath(
1080 SourceUrl::from("https://example.com/repo.git"),
1081 Some(SourceSubpath::new("plugins/foo").unwrap()),
1082 );
1083
1084 assert_eq!(a, b);
1085
1086 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1087 a.hash(&mut hasher_a);
1088 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1089 b.hash(&mut hasher_b);
1090 assert_eq!(hasher_a.finish(), hasher_b.finish());
1091 }
1092
1093 #[test]
1094 fn source_id_git_same_url_different_subpaths_are_distinct() {
1095 let a = SourceId::git_with_subpath(
1096 SourceUrl::from("https://example.com/repo.git"),
1097 Some(SourceSubpath::new("plugins/foo").unwrap()),
1098 );
1099 let b = SourceId::git_with_subpath(
1100 SourceUrl::from("https://example.com/repo.git"),
1101 Some(SourceSubpath::new("plugins/bar").unwrap()),
1102 );
1103
1104 assert_ne!(a, b);
1105
1106 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1107 a.hash(&mut hasher_a);
1108 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1109 b.hash(&mut hasher_b);
1110 assert_ne!(hasher_a.finish(), hasher_b.finish());
1111 }
1112
1113 #[test]
1119 fn source_id_path_none_and_some_subpath_hash_distinctly() {
1120 let canonical = PathBuf::from("/tmp/my-repo");
1121 let a = SourceId::Path {
1122 canonical: canonical.clone(),
1123 subpath: None,
1124 };
1125 let b = SourceId::Path {
1126 canonical: canonical.clone(),
1127 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1128 };
1129
1130 assert_ne!(a, b);
1131
1132 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1133 a.hash(&mut hasher_a);
1134 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1135 b.hash(&mut hasher_b);
1136 assert_ne!(hasher_a.finish(), hasher_b.finish());
1137 }
1138
1139 #[test]
1142 fn source_id_path_same_canonical_same_subpath_are_equal() {
1143 let canonical = PathBuf::from("/tmp/my-repo");
1144 let a = SourceId::Path {
1145 canonical: canonical.clone(),
1146 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1147 };
1148 let b = SourceId::Path {
1149 canonical: canonical.clone(),
1150 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1151 };
1152
1153 assert_eq!(a, b);
1154
1155 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1156 a.hash(&mut hasher_a);
1157 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1158 b.hash(&mut hasher_b);
1159 assert_eq!(hasher_a.finish(), hasher_b.finish());
1160 }
1161
1162 #[test]
1165 fn source_id_path_same_canonical_different_subpaths_are_distinct() {
1166 let canonical = PathBuf::from("/tmp/my-repo");
1167 let a = SourceId::Path {
1168 canonical: canonical.clone(),
1169 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1170 };
1171 let b = SourceId::Path {
1172 canonical: canonical.clone(),
1173 subpath: Some(SourceSubpath::new("plugins/bar").unwrap()),
1174 };
1175
1176 assert_ne!(a, b);
1177
1178 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1179 a.hash(&mut hasher_a);
1180 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1181 b.hash(&mut hasher_b);
1182 assert_ne!(hasher_a.finish(), hasher_b.finish());
1183 }
1184
1185 #[test]
1191 fn lock_write_and_load_roundtrip_preserves_subpath() {
1192 use crate::lock::{LockFile, LockedSource};
1193 use tempfile::TempDir;
1194
1195 let dir = TempDir::new().unwrap();
1196 let lock = LockFile {
1197 version: 1,
1198 dependencies: indexmap::IndexMap::from([(
1199 SourceName::from("dep"),
1200 LockedSource {
1201 url: Some(SourceUrl::from("https://github.com/org/repo.git")),
1202 path: None,
1203 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1204 version: Some("v1.2.3".to_string()),
1205 commit: Some(CommitHash::from("deadbeef")),
1206 tree_hash: None,
1207 },
1208 )]),
1209 items: indexmap::IndexMap::new(),
1210 config_entries: std::collections::BTreeMap::new(),
1211 };
1212
1213 crate::lock::write(dir.path(), &lock).unwrap();
1214 let loaded = crate::lock::load(dir.path()).unwrap();
1215
1216 assert_eq!(
1217 loaded.dependencies["dep"]
1218 .subpath
1219 .as_ref()
1220 .map(SourceSubpath::as_str),
1221 Some("plugins/foo")
1222 );
1223 assert_eq!(
1224 loaded.dependencies["dep"].url.as_deref(),
1225 Some("https://github.com/org/repo.git")
1226 );
1227 assert_eq!(
1228 loaded.dependencies["dep"].version.as_deref(),
1229 Some("v1.2.3")
1230 );
1231 }
1232
1233 #[test]
1239 fn effective_dependency_subpath_preserved_through_merge() {
1240 use crate::config::{Config, merge};
1241
1242 let toml_str = r#"
1243[dependencies.dep]
1244url = "https://github.com/org/repo.git"
1245subpath = "plugins/foo"
1246"#;
1247 let config: Config = toml::from_str(toml_str).unwrap();
1248 let effective = merge(config, crate::config::LocalConfig::default()).unwrap();
1249 assert_eq!(
1250 effective.dependencies["dep"]
1251 .subpath
1252 .as_ref()
1253 .map(SourceSubpath::as_str),
1254 Some("plugins/foo")
1255 );
1256 assert!(matches!(
1258 &effective.dependencies["dep"].id,
1259 SourceId::Git { subpath: Some(sp), .. } if sp.as_str() == "plugins/foo"
1260 ));
1261 }
1262}