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
535pub fn managed_cmd(cmd: &str) -> std::borrow::Cow<'_, str> {
539 if meridian_managed_from_env() {
540 format!("meridian {cmd}").into()
541 } else {
542 cmd.into()
543 }
544}
545
546#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd, Serialize, Deserialize)]
548pub enum SourceId {
549 Git {
550 url: SourceUrl,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
552 subpath: Option<SourceSubpath>,
553 },
554 Path {
555 canonical: PathBuf,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
557 subpath: Option<SourceSubpath>,
558 },
559}
560
561impl SourceId {
562 pub fn git(url: SourceUrl) -> Self {
563 Self::Git { url, subpath: None }
564 }
565
566 pub fn git_with_subpath(url: SourceUrl, subpath: Option<SourceSubpath>) -> Self {
567 Self::Git { url, subpath }
568 }
569
570 pub fn path(base: &Path, relative_or_absolute: &Path) -> std::io::Result<Self> {
571 Self::path_with_subpath(base, relative_or_absolute, None)
572 }
573
574 pub fn path_with_subpath(
575 base: &Path,
576 relative_or_absolute: &Path,
577 subpath: Option<SourceSubpath>,
578 ) -> std::io::Result<Self> {
579 let candidate = if relative_or_absolute.is_absolute() {
580 relative_or_absolute.to_path_buf()
581 } else {
582 base.join(relative_or_absolute)
583 };
584 let canonical = dunce::canonicalize(&candidate)?;
585 Ok(Self::Path { canonical, subpath })
586 }
587}
588
589impl fmt::Display for SourceId {
590 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
591 match self {
592 Self::Git { url, subpath } => {
593 write!(f, "git:{url}")?;
594 if let Some(subpath) = subpath {
595 write!(f, "@{subpath}")?;
596 }
597 Ok(())
598 }
599 Self::Path { canonical, subpath } => {
600 write!(f, "path:{}", canonical.display())?;
601 if let Some(subpath) = subpath {
602 write!(f, "@{subpath}")?;
603 }
604 Ok(())
605 }
606 }
607 }
608}
609
610#[derive(Debug, Clone, PartialEq, Eq)]
611pub struct RenameRule {
612 pub from: ItemName,
613 pub to: ItemName,
614}
615
616#[derive(Debug, Clone, Default, PartialEq, Eq)]
618pub struct RenameMap(Vec<RenameRule>);
619
620impl RenameMap {
621 pub fn new() -> Self {
622 Self(Vec::new())
623 }
624
625 pub fn insert(&mut self, from: ItemName, to: ItemName) {
626 if let Some(existing) = self.0.iter_mut().find(|r| r.from == from) {
627 existing.to = to;
628 return;
629 }
630 self.0.push(RenameRule { from, to });
631 }
632
633 pub fn push(&mut self, rule: RenameRule) {
634 self.insert(rule.from, rule.to);
635 }
636
637 pub fn get(&self, from: &str) -> Option<&ItemName> {
638 self.0.iter().find(|r| r.from == from).map(|r| &r.to)
639 }
640
641 pub fn iter(&self) -> impl Iterator<Item = &RenameRule> {
642 self.0.iter()
643 }
644
645 pub fn is_empty(&self) -> bool {
646 self.0.is_empty()
647 }
648
649 pub fn len(&self) -> usize {
650 self.0.len()
651 }
652}
653
654impl Serialize for RenameMap {
655 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
656 use serde::ser::SerializeMap;
657 let mut map = serializer.serialize_map(Some(self.0.len()))?;
658 for rule in &self.0 {
659 map.serialize_entry(rule.from.as_str(), rule.to.as_str())?;
660 }
661 map.end()
662 }
663}
664
665impl<'de> Deserialize<'de> for RenameMap {
666 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
667 let map = indexmap::IndexMap::<String, String>::deserialize(deserializer)?;
668 Ok(Self(
669 map.into_iter()
670 .map(|(from, to)| RenameRule {
671 from: ItemName::from(from),
672 to: ItemName::from(to),
673 })
674 .collect(),
675 ))
676 }
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682 use serde::{Deserialize, Serialize};
683 use std::path::PathBuf;
684
685 #[derive(Debug, Serialize, Deserialize, PartialEq)]
686 struct Wrapper<T> {
687 value: T,
688 }
689
690 #[test]
691 fn dest_path_roundtrip() {
692 let v = Wrapper {
693 value: DestPath::from("agents/coder.md"),
694 };
695 let s = toml::to_string(&v).unwrap();
696 let out: Wrapper<DestPath> = toml::from_str(&s).unwrap();
697 assert_eq!(v, out);
698 }
699
700 #[test]
701 fn rename_map_toml_roundtrip_compat() {
702 #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
703 struct RenameWrapper {
704 rename: RenameMap,
705 }
706
707 let input = r#"rename = { "coder" = "cool-coder" }"#;
708 let parsed: RenameWrapper = toml::from_str(input).unwrap();
709 assert_eq!(
710 parsed.rename.get("coder").map(|v| v.as_str()),
711 Some("cool-coder")
712 );
713
714 let serialized = toml::to_string(&parsed).unwrap();
715 let reparsed: RenameWrapper = toml::from_str(&serialized).unwrap();
716 assert_eq!(parsed, reparsed);
717 }
718
719 #[test]
720 fn source_subpath_normalizes_windows_and_unix_separators() {
721 let subpath = SourceSubpath::new(r"plugins\foo/bar\baz").unwrap();
722 assert_eq!(subpath.as_str(), "plugins/foo/bar/baz");
723 }
724
725 #[test]
726 fn source_subpath_and_dest_path_share_normalization_rules() {
727 let raw = r"./plugins\foo/bar\";
728 let subpath = SourceSubpath::new(raw).unwrap();
729 let dest = DestPath::new(raw).unwrap();
730
731 assert_eq!(subpath.as_str(), "plugins/foo/bar");
732 assert_eq!(dest.as_str(), "plugins/foo/bar");
733 assert_eq!(subpath.as_str(), dest.as_str());
734 }
735
736 #[test]
737 fn source_subpath_rejects_empty() {
738 let err = SourceSubpath::new("").unwrap_err();
739 assert_eq!(err, SourceSubpathError::Empty);
740 }
741
742 #[test]
743 fn source_subpath_rejects_absolute() {
744 let err = SourceSubpath::new("/abs/path").unwrap_err();
745 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
746 }
747
748 #[test]
749 fn source_subpath_rejects_root_only() {
750 let err = SourceSubpath::new("/").unwrap_err();
751 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
752 }
753
754 #[test]
755 fn source_subpath_rejects_windows_absolute() {
756 let err = SourceSubpath::new(r"C:\abs\path").unwrap_err();
757 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
758 }
759
760 #[test]
761 fn source_subpath_rejects_escape() {
762 let err = SourceSubpath::new("../escape").unwrap_err();
763 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
764 }
765
766 #[test]
767 fn source_subpath_accepts_nested_relative_path() {
768 let subpath = SourceSubpath::new("a/b/c").unwrap();
769 assert_eq!(subpath.as_str(), "a/b/c");
770 }
771
772 #[test]
773 fn source_subpath_accepts_plugins_foo() {
774 let subpath = SourceSubpath::new("plugins/foo").unwrap();
775 assert_eq!(subpath.as_str(), "plugins/foo");
776 }
777
778 #[test]
779 fn source_subpath_serializes_with_forward_slashes() {
780 #[derive(Debug, Serialize, Deserialize, PartialEq)]
781 struct SubpathWrapper {
782 subpath: SourceSubpath,
783 }
784
785 let wrapper = SubpathWrapper {
786 subpath: SourceSubpath::new(r"plugins\foo").unwrap(),
787 };
788 let toml = toml::to_string(&wrapper).unwrap();
789 assert!(toml.contains("subpath = \"plugins/foo\""));
790 }
791
792 #[test]
793 fn source_subpath_join_under_base() {
794 let base = PathBuf::from("/tmp/mars");
795 let subpath = SourceSubpath::new("plugins/foo").unwrap();
796 let joined = subpath.join_under(&base).unwrap();
797 assert_eq!(joined, base.join("plugins").join("foo"));
798 }
799
800 #[test]
801 fn source_subpath_join_under_rejects_escape_path() {
802 let escaped = SourceSubpath(String::from("../escape"));
803 let err = escaped.join_under(Path::new("/tmp/base")).unwrap_err();
804 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
805 }
806
807 #[test]
811 fn source_subpath_accepts_deeply_nested() {
812 let subpath = SourceSubpath::new("a/b/c/d/e").unwrap();
813 assert_eq!(subpath.as_str(), "a/b/c/d/e");
814 }
815
816 #[test]
818 fn source_subpath_rejects_windows_drive_forward_slash() {
819 let err = SourceSubpath::new("C:/foo").unwrap_err();
820 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
821 }
822
823 #[test]
825 fn source_subpath_rejects_current_dir_dot() {
826 let err = SourceSubpath::new(".").unwrap_err();
827 assert_eq!(err, SourceSubpathError::Empty);
828 }
829
830 #[test]
831 fn dest_path_normalizes_windows_and_unix_separators() {
832 let path = DestPath::new(r"agents\foo/bar\baz.md").unwrap();
833 assert_eq!(path.as_str(), "agents/foo/bar/baz.md");
834 }
835
836 #[test]
837 fn dest_path_rejects_empty() {
838 let err = DestPath::new("").unwrap_err();
839 assert_eq!(err, DestPathError::Empty);
840 }
841
842 #[test]
843 fn dest_path_rejects_absolute() {
844 let err = DestPath::new("/abs/path").unwrap_err();
845 assert!(matches!(err, DestPathError::Absolute { .. }));
846 }
847
848 #[test]
849 fn dest_path_rejects_root_only() {
850 let err = DestPath::new("/").unwrap_err();
851 assert!(matches!(err, DestPathError::Absolute { .. }));
852 }
853
854 #[test]
855 fn dest_path_rejects_windows_absolute() {
856 let err = DestPath::new(r"C:\abs\path").unwrap_err();
857 assert!(matches!(err, DestPathError::Absolute { .. }));
858 }
859
860 #[test]
861 fn dest_path_rejects_windows_drive_relative() {
862 let err = DestPath::new("C:relative").unwrap_err();
863 assert!(matches!(err, DestPathError::Absolute { .. }));
864 }
865
866 #[test]
867 fn dest_path_rejects_escape() {
868 let err = DestPath::new("../escape").unwrap_err();
869 assert!(matches!(err, DestPathError::Escaping { .. }));
870 }
871
872 #[test]
873 fn dest_path_normalizes_trailing_slash() {
874 let path = DestPath::new("skills/planning/").unwrap();
875 assert_eq!(path.as_str(), "skills/planning");
876 }
877
878 #[test]
879 fn dest_path_normalizes_leading_dot_slash() {
880 let path = DestPath::new("./skills/planning").unwrap();
881 assert_eq!(path.as_str(), "skills/planning");
882 }
883
884 #[test]
885 fn dest_path_item_name_extracts_agent_leaf() {
886 let path = DestPath::new("agents/coder.md").unwrap();
887 assert_eq!(path.item_name(ItemKind::Agent), "coder");
888 }
889
890 #[test]
891 fn dest_path_item_name_extracts_skill_leaf() {
892 let path = DestPath::new("skills/planning").unwrap();
893 assert_eq!(path.item_name(ItemKind::Skill), "planning");
894 }
895
896 #[test]
897 fn dest_path_item_name_extracts_bootstrap_doc_container() {
898 let path = DestPath::new("bootstrap/global-auth/BOOTSTRAP.md").unwrap();
899 assert_eq!(path.item_name(ItemKind::BootstrapDoc), "global-auth");
900 }
901
902 #[test]
903 fn dest_path_item_name_extracts_nested_agent_leaf() {
904 let path = DestPath::new("agents/sub/deep.md").unwrap();
905 assert_eq!(path.item_name(ItemKind::Agent), "deep");
906 }
907
908 #[test]
909 fn dest_path_item_name_handles_no_slash_edge_case() {
910 let path = DestPath::new("solo.md").unwrap();
911 assert_eq!(path.item_name(ItemKind::Agent), "solo");
912 }
913
914 #[test]
917 fn source_subpath_rejects_mid_path_double_parent_escape() {
918 let err = SourceSubpath::new("a/../../escape").unwrap_err();
919 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
920 }
921
922 #[test]
925 fn source_subpath_rejects_harmless_parent_in_middle() {
926 let err = SourceSubpath::new("a/b/../c").unwrap_err();
927 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
928 }
929
930 #[test]
932 fn source_subpath_normalizes_trailing_slash() {
933 let subpath = SourceSubpath::new("plugins/foo/").unwrap();
934 assert_eq!(subpath.as_str(), "plugins/foo");
935 }
936
937 #[test]
939 fn source_subpath_normalizes_leading_dot_slash() {
940 let subpath = SourceSubpath::new("./plugins/foo").unwrap();
941 assert_eq!(subpath.as_str(), "plugins/foo");
942 }
943
944 #[test]
946 fn source_subpath_join_under_base_with_trailing_slash() {
947 let base = PathBuf::from("/tmp/mars/");
948 let subpath = SourceSubpath::new("plugins/foo").unwrap();
949 let joined = subpath.join_under(&base).unwrap();
950 assert_eq!(joined, PathBuf::from("/tmp/mars/plugins/foo"));
952 }
953
954 #[test]
956 fn locked_source_json_roundtrip_without_subpath() {
957 let json = r#"{"url":"https://github.com/org/base.git"}"#;
958 let parsed: crate::lock::LockedSource = serde_json::from_str(json).unwrap();
959 assert!(parsed.subpath.is_none());
960 }
961
962 #[test]
964 fn locked_source_json_roundtrip_with_subpath() {
965 let source = crate::lock::LockedSource {
966 url: Some(SourceUrl::from("https://github.com/org/base.git")),
967 path: None,
968 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
969 version: None,
970 commit: None,
971 tree_hash: None,
972 };
973 let json = serde_json::to_string(&source).unwrap();
974 assert!(json.contains("\"subpath\":\"plugins/foo\""));
975 let reparsed: crate::lock::LockedSource = serde_json::from_str(&json).unwrap();
976 assert_eq!(
977 reparsed.subpath.as_ref().map(SourceSubpath::as_str),
978 Some("plugins/foo")
979 );
980 }
981
982 #[test]
984 fn locked_source_toml_missing_subpath_field_is_none() {
985 let toml_str = r#"
986version = 1
987
988[dependencies.dep]
989url = "https://github.com/org/dep.git"
990commit = "deadbeef"
991"#;
992 let lock: crate::lock::LockFile = toml::from_str(toml_str).unwrap();
993 assert!(lock.dependencies["dep"].subpath.is_none());
994 }
995
996 #[test]
998 fn locked_source_toml_subpath_serializes_alongside_other_fields() {
999 let source = crate::lock::LockedSource {
1000 url: Some(SourceUrl::from("https://github.com/org/base.git")),
1001 path: None,
1002 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1003 version: Some("v1.0.0".to_string()),
1004 commit: Some(CommitHash::from("abc123")),
1005 tree_hash: None,
1006 };
1007 #[derive(Serialize)]
1008 struct Wrapper {
1009 source: crate::lock::LockedSource,
1010 }
1011 let serialized = toml::to_string(&Wrapper { source }).unwrap();
1012 assert!(serialized.contains("subpath = \"plugins/foo\""));
1013 assert!(serialized.contains("url = "));
1014 assert!(serialized.contains("commit = "));
1015 }
1016
1017 #[test]
1018 fn lock_roundtrip_with_and_without_subpath() {
1019 let old_lock = r#"
1020version = 1
1021
1022[dependencies.base]
1023url = "https://github.com/org/base.git"
1024"#;
1025 let parsed_old: crate::lock::LockFile = toml::from_str(old_lock).unwrap();
1026 assert!(parsed_old.dependencies["base"].subpath.is_none());
1027
1028 let lock = crate::lock::LockFile {
1029 version: 1,
1030 dependencies: indexmap::IndexMap::from([(
1031 SourceName::from("base"),
1032 crate::lock::LockedSource {
1033 url: Some(SourceUrl::from("https://github.com/org/base.git")),
1034 path: None,
1035 subpath: Some(SourceSubpath::new(r"plugins\foo").unwrap()),
1036 version: Some("v1.2.3".to_string()),
1037 commit: Some(CommitHash::from("abc123")),
1038 tree_hash: None,
1039 },
1040 )]),
1041 items: indexmap::IndexMap::new(),
1042 config_entries: std::collections::BTreeMap::new(),
1043 };
1044 let serialized = toml::to_string_pretty(&lock).unwrap();
1045 assert!(serialized.contains("subpath = \"plugins/foo\""));
1046 let reparsed: crate::lock::LockFile = toml::from_str(&serialized).unwrap();
1047 assert_eq!(
1048 reparsed.dependencies["base"]
1049 .subpath
1050 .as_ref()
1051 .map(SourceSubpath::as_str),
1052 Some("plugins/foo")
1053 );
1054 }
1055
1056 #[test]
1057 fn config_roundtrip_preserves_subpath() {
1058 let config = r#"
1059[dependencies.base]
1060url = "https://github.com/org/base.git"
1061subpath = "plugins\\foo"
1062"#;
1063 let parsed: crate::config::Config = toml::from_str(config).unwrap();
1064 assert_eq!(
1065 parsed.dependencies["base"]
1066 .subpath
1067 .as_ref()
1068 .map(SourceSubpath::as_str),
1069 Some("plugins/foo")
1070 );
1071
1072 let serialized = toml::to_string(&parsed).unwrap();
1073 assert!(serialized.contains("subpath = \"plugins/foo\""));
1074 let reparsed: crate::config::Config = toml::from_str(&serialized).unwrap();
1075 assert_eq!(
1076 reparsed.dependencies["base"]
1077 .subpath
1078 .as_ref()
1079 .map(SourceSubpath::as_str),
1080 Some("plugins/foo")
1081 );
1082 }
1083
1084 #[test]
1085 fn source_id_git_same_url_same_subpath_are_equal_and_hash_equal() {
1086 let a = SourceId::git_with_subpath(
1087 SourceUrl::from("https://example.com/repo.git"),
1088 Some(SourceSubpath::new("plugins/foo").unwrap()),
1089 );
1090 let b = SourceId::git_with_subpath(
1091 SourceUrl::from("https://example.com/repo.git"),
1092 Some(SourceSubpath::new("plugins/foo").unwrap()),
1093 );
1094
1095 assert_eq!(a, b);
1096
1097 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1098 a.hash(&mut hasher_a);
1099 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1100 b.hash(&mut hasher_b);
1101 assert_eq!(hasher_a.finish(), hasher_b.finish());
1102 }
1103
1104 #[test]
1105 fn source_id_git_same_url_different_subpaths_are_distinct() {
1106 let a = SourceId::git_with_subpath(
1107 SourceUrl::from("https://example.com/repo.git"),
1108 Some(SourceSubpath::new("plugins/foo").unwrap()),
1109 );
1110 let b = SourceId::git_with_subpath(
1111 SourceUrl::from("https://example.com/repo.git"),
1112 Some(SourceSubpath::new("plugins/bar").unwrap()),
1113 );
1114
1115 assert_ne!(a, b);
1116
1117 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1118 a.hash(&mut hasher_a);
1119 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1120 b.hash(&mut hasher_b);
1121 assert_ne!(hasher_a.finish(), hasher_b.finish());
1122 }
1123
1124 #[test]
1130 fn source_id_path_none_and_some_subpath_hash_distinctly() {
1131 let canonical = PathBuf::from("/tmp/my-repo");
1132 let a = SourceId::Path {
1133 canonical: canonical.clone(),
1134 subpath: None,
1135 };
1136 let b = SourceId::Path {
1137 canonical: canonical.clone(),
1138 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1139 };
1140
1141 assert_ne!(a, b);
1142
1143 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1144 a.hash(&mut hasher_a);
1145 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1146 b.hash(&mut hasher_b);
1147 assert_ne!(hasher_a.finish(), hasher_b.finish());
1148 }
1149
1150 #[test]
1153 fn source_id_path_same_canonical_same_subpath_are_equal() {
1154 let canonical = PathBuf::from("/tmp/my-repo");
1155 let a = SourceId::Path {
1156 canonical: canonical.clone(),
1157 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1158 };
1159 let b = SourceId::Path {
1160 canonical: canonical.clone(),
1161 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1162 };
1163
1164 assert_eq!(a, b);
1165
1166 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1167 a.hash(&mut hasher_a);
1168 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1169 b.hash(&mut hasher_b);
1170 assert_eq!(hasher_a.finish(), hasher_b.finish());
1171 }
1172
1173 #[test]
1176 fn source_id_path_same_canonical_different_subpaths_are_distinct() {
1177 let canonical = PathBuf::from("/tmp/my-repo");
1178 let a = SourceId::Path {
1179 canonical: canonical.clone(),
1180 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1181 };
1182 let b = SourceId::Path {
1183 canonical: canonical.clone(),
1184 subpath: Some(SourceSubpath::new("plugins/bar").unwrap()),
1185 };
1186
1187 assert_ne!(a, b);
1188
1189 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1190 a.hash(&mut hasher_a);
1191 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1192 b.hash(&mut hasher_b);
1193 assert_ne!(hasher_a.finish(), hasher_b.finish());
1194 }
1195
1196 #[test]
1202 fn lock_write_and_load_roundtrip_preserves_subpath() {
1203 use crate::lock::{LockFile, LockedSource};
1204 use tempfile::TempDir;
1205
1206 let dir = TempDir::new().unwrap();
1207 let lock = LockFile {
1208 version: 1,
1209 dependencies: indexmap::IndexMap::from([(
1210 SourceName::from("dep"),
1211 LockedSource {
1212 url: Some(SourceUrl::from("https://github.com/org/repo.git")),
1213 path: None,
1214 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1215 version: Some("v1.2.3".to_string()),
1216 commit: Some(CommitHash::from("deadbeef")),
1217 tree_hash: None,
1218 },
1219 )]),
1220 items: indexmap::IndexMap::new(),
1221 config_entries: std::collections::BTreeMap::new(),
1222 };
1223
1224 crate::lock::write(dir.path(), &lock).unwrap();
1225 let loaded = crate::lock::load(dir.path()).unwrap();
1226
1227 assert_eq!(
1228 loaded.dependencies["dep"]
1229 .subpath
1230 .as_ref()
1231 .map(SourceSubpath::as_str),
1232 Some("plugins/foo")
1233 );
1234 assert_eq!(
1235 loaded.dependencies["dep"].url.as_deref(),
1236 Some("https://github.com/org/repo.git")
1237 );
1238 assert_eq!(
1239 loaded.dependencies["dep"].version.as_deref(),
1240 Some("v1.2.3")
1241 );
1242 }
1243
1244 #[test]
1250 fn effective_dependency_subpath_preserved_through_merge() {
1251 use crate::config::{Config, merge};
1252
1253 let toml_str = r#"
1254[dependencies.dep]
1255url = "https://github.com/org/repo.git"
1256subpath = "plugins/foo"
1257"#;
1258 let config: Config = toml::from_str(toml_str).unwrap();
1259 let effective = merge(config, crate::config::LocalConfig::default()).unwrap();
1260 assert_eq!(
1261 effective.dependencies["dep"]
1262 .subpath
1263 .as_ref()
1264 .map(SourceSubpath::as_str),
1265 Some("plugins/foo")
1266 );
1267 assert!(matches!(
1269 &effective.dependencies["dep"].id,
1270 SourceId::Git { subpath: Some(sp), .. } if sp.as_str() == "plugins/foo"
1271 ));
1272 }
1273}