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 From<String> for $name {
18 fn from(value: String) -> Self {
19 Self(value)
20 }
21 }
22
23 impl From<&str> for $name {
24 fn from(value: &str) -> Self {
25 Self(value.to_owned())
26 }
27 }
28
29 impl AsRef<str> for $name {
30 fn as_ref(&self) -> &str {
31 &self.0
32 }
33 }
34
35 impl Borrow<str> for $name {
36 fn borrow(&self) -> &str {
37 &self.0
38 }
39 }
40
41 impl Deref for $name {
42 type Target = str;
43
44 fn deref(&self) -> &Self::Target {
45 &self.0
46 }
47 }
48
49 impl From<$name> for String {
50 fn from(value: $name) -> Self {
51 value.0
52 }
53 }
54
55 impl fmt::Display for $name {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 f.write_str(&self.0)
58 }
59 }
60
61 impl PartialEq<str> for $name {
62 fn eq(&self, other: &str) -> bool {
63 self.0 == other
64 }
65 }
66
67 impl PartialEq<&str> for $name {
68 fn eq(&self, other: &&str) -> bool {
69 self.0 == *other
70 }
71 }
72
73 impl PartialEq<String> for $name {
74 fn eq(&self, other: &String) -> bool {
75 self.0 == *other
76 }
77 }
78
79 impl PartialEq<$name> for String {
80 fn eq(&self, other: &$name) -> bool {
81 *self == other.0
82 }
83 }
84 };
85}
86
87string_newtype!(SourceName);
88string_newtype!(ItemName);
89string_newtype!(SourceUrl);
90string_newtype!(CommitHash);
91string_newtype!(ContentHash);
92
93macro_rules! impl_as_str {
94 ($($name:ident),+ $(,)?) => {
95 $(
96 impl $name {
97 pub fn as_str(&self) -> &str {
98 &self.0
99 }
100 }
101 )+
102 };
103}
104
105impl_as_str!(SourceName, ItemName, SourceUrl, 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 pub fn join_under(&self, base: &Path) -> Result<PathBuf, SourceSubpathError> {
176 let mut joined = base.to_path_buf();
177 for component in self.as_path().components() {
178 match component {
179 Component::Normal(seg) => joined.push(seg),
180 Component::CurDir => {}
181 Component::ParentDir => {
182 return Err(SourceSubpathError::Escaping {
183 input: self.0.clone(),
184 });
185 }
186 Component::RootDir | Component::Prefix(_) => {
187 return Err(SourceSubpathError::Absolute {
188 input: self.0.clone(),
189 });
190 }
191 }
192 }
193
194 if joined.strip_prefix(base).is_err() {
195 return Err(SourceSubpathError::Escaping {
196 input: self.0.clone(),
197 });
198 }
199
200 Ok(joined)
201 }
202}
203
204impl fmt::Display for SourceSubpath {
205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206 f.write_str(&self.0)
207 }
208}
209
210impl AsRef<str> for SourceSubpath {
211 fn as_ref(&self) -> &str {
212 self.as_str()
213 }
214}
215
216impl std::str::FromStr for SourceSubpath {
217 type Err = SourceSubpathError;
218
219 fn from_str(s: &str) -> Result<Self, Self::Err> {
220 Self::new(s)
221 }
222}
223
224impl Serialize for SourceSubpath {
225 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
226 self.0.serialize(serializer)
227 }
228}
229
230impl<'de> Deserialize<'de> for SourceSubpath {
231 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
232 let value = String::deserialize(deserializer)?;
233 SourceSubpath::new(value).map_err(serde::de::Error::custom)
234 }
235}
236
237#[derive(Debug, thiserror::Error, PartialEq, Eq)]
238pub enum SourceSubpathError {
239 #[error("subpath cannot be empty")]
240 Empty,
241 #[error("subpath must be relative, got absolute value: {input:?}")]
242 Absolute { input: String },
243 #[error("subpath cannot escape package root: {input:?}")]
244 Escaping { input: String },
245}
246
247#[derive(Debug, thiserror::Error, PartialEq, Eq)]
248pub enum DestPathError {
249 #[error("destination path cannot be empty")]
250 Empty,
251 #[error("destination path must be relative, got absolute value: {input:?}")]
252 Absolute { input: String },
253 #[error("destination path cannot escape target root: {input:?}")]
254 Escaping { input: String },
255}
256
257fn is_windows_absolute(path: &str) -> bool {
258 let bytes = path.as_bytes();
259 if path.starts_with('/') {
260 return true;
261 }
262 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
263 return true;
264 }
265 false
266}
267
268fn is_windows_drive_relative(path: &str) -> bool {
269 let bytes = path.as_bytes();
270 bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
275pub enum SourceOrigin {
276 Dependency(SourceName),
278 LocalPackage,
280}
281
282impl fmt::Display for SourceOrigin {
283 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284 match self {
285 Self::Dependency(name) => write!(f, "{name}"),
286 Self::LocalPackage => write!(f, "_self"),
287 }
288 }
289}
290
291#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
293#[serde(rename_all = "lowercase")]
294pub enum ItemKind {
295 Agent,
296 Skill,
297 Hook,
298 McpServer,
299 BootstrapDoc,
300}
301
302impl fmt::Display for ItemKind {
303 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304 match self {
305 ItemKind::Agent => write!(f, "agent"),
306 ItemKind::Skill => write!(f, "skill"),
307 ItemKind::Hook => write!(f, "hook"),
308 ItemKind::McpServer => write!(f, "mcp-server"),
309 ItemKind::BootstrapDoc => write!(f, "bootstrap-doc"),
310 }
311 }
312}
313
314#[derive(Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
319pub struct ItemId {
320 pub kind: ItemKind,
321 pub name: ItemName,
322}
323
324impl fmt::Display for ItemId {
325 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326 write!(f, "{}/{}", self.kind, self.name)
327 }
328}
329
330#[derive(Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
333pub struct DestPath(String);
334
335impl DestPath {
336 pub fn new(value: impl AsRef<str>) -> Result<Self, DestPathError> {
338 let raw = value.as_ref();
339 if raw.is_empty() {
340 return Err(DestPathError::Empty);
341 }
342
343 let normalized_separators = raw.replace('\\', "/");
344 if is_windows_absolute(&normalized_separators)
345 || is_windows_drive_relative(&normalized_separators)
346 {
347 return Err(DestPathError::Absolute {
348 input: raw.to_string(),
349 });
350 }
351
352 let normalized = normalize_relative_coordinate(raw).map_err(|err| match err {
353 NormalizeError::Empty => DestPathError::Empty,
354 NormalizeError::Absolute => DestPathError::Absolute {
355 input: raw.to_string(),
356 },
357 NormalizeError::Escaping => DestPathError::Escaping {
358 input: raw.to_string(),
359 },
360 })?;
361
362 Ok(Self(normalized))
363 }
364
365 pub fn as_str(&self) -> &str {
367 &self.0
368 }
369 pub fn resolve(&self, root: &Path) -> PathBuf {
371 let mut result = root.to_path_buf();
372 for component in self.components() {
373 result.push(component);
374 }
375 result
376 }
377
378 pub fn components(&self) -> impl Iterator<Item = &str> {
380 self.0.split('/')
381 }
382
383 pub fn item_name(&self, kind: ItemKind) -> String {
388 match kind {
389 ItemKind::BootstrapDoc => self
390 .0
391 .strip_suffix("/BOOTSTRAP.md")
392 .and_then(|path| path.rsplit('/').next())
393 .unwrap_or_else(|| self.0.rsplit('/').next().unwrap_or(""))
394 .to_string(),
395 _ => {
396 let last = self.0.rsplit('/').next().unwrap_or("");
397 match kind {
398 ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
399 ItemKind::Skill | ItemKind::Hook | ItemKind::McpServer => last.to_string(),
400 ItemKind::BootstrapDoc => unreachable!("handled above"),
401 }
402 }
403 }
404 }
405}
406
407impl From<&str> for DestPath {
408 fn from(value: &str) -> Self {
409 Self::new(value).expect("invalid destination path")
410 }
411}
412
413impl From<String> for DestPath {
414 fn from(value: String) -> Self {
415 Self::new(value).expect("invalid destination path")
416 }
417}
418
419impl AsRef<str> for DestPath {
420 fn as_ref(&self) -> &str {
421 &self.0
422 }
423}
424
425impl Borrow<str> for DestPath {
426 fn borrow(&self) -> &str {
427 &self.0
428 }
429}
430
431impl Hash for DestPath {
432 fn hash<H: Hasher>(&self, state: &mut H) {
433 self.0.hash(state);
434 }
435}
436
437impl fmt::Display for DestPath {
438 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
439 f.write_str(&self.0)
440 }
441}
442
443impl Serialize for DestPath {
444 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
445 self.0.serialize(serializer)
446 }
447}
448
449impl<'de> Deserialize<'de> for DestPath {
450 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
451 let value = String::deserialize(deserializer)?;
452 DestPath::new(value).map_err(serde::de::Error::custom)
453 }
454}
455
456#[derive(Debug, Clone)]
458pub struct MarsContext {
459 pub project_root: PathBuf,
461 pub meridian_managed: bool,
466}
467
468#[cfg(test)]
469impl MarsContext {
470 pub fn for_test(project_root: PathBuf) -> Self {
472 MarsContext {
473 project_root,
474 meridian_managed: meridian_managed_from_env(),
475 }
476 }
477}
478
479pub fn meridian_managed_from_env() -> bool {
480 std::env::var("MERIDIAN_MANAGED").is_ok_and(|value| value == "1")
481}
482
483pub fn managed_cmd(cmd: &str) -> std::borrow::Cow<'_, str> {
487 if meridian_managed_from_env() {
488 format!("meridian {cmd}").into()
489 } else {
490 cmd.into()
491 }
492}
493
494#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd, Serialize, Deserialize)]
496pub enum SourceId {
497 Git {
498 url: SourceUrl,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
500 subpath: Option<SourceSubpath>,
501 },
502 Path {
503 canonical: PathBuf,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
505 subpath: Option<SourceSubpath>,
506 },
507}
508
509impl SourceId {
510 pub fn git_with_subpath(url: SourceUrl, subpath: Option<SourceSubpath>) -> Self {
511 Self::Git { url, subpath }
512 }
513 pub fn path_with_subpath(
514 base: &Path,
515 relative_or_absolute: &Path,
516 subpath: Option<SourceSubpath>,
517 ) -> std::io::Result<Self> {
518 let candidate = if relative_or_absolute.is_absolute() {
519 relative_or_absolute.to_path_buf()
520 } else {
521 base.join(relative_or_absolute)
522 };
523 let canonical = dunce::canonicalize(&candidate)?;
524 Ok(Self::Path { canonical, subpath })
525 }
526}
527
528impl fmt::Display for SourceId {
529 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
530 match self {
531 Self::Git { url, subpath } => {
532 write!(f, "git:{url}")?;
533 if let Some(subpath) = subpath {
534 write!(f, "@{subpath}")?;
535 }
536 Ok(())
537 }
538 Self::Path { canonical, subpath } => {
539 write!(f, "path:{}", canonical.display())?;
540 if let Some(subpath) = subpath {
541 write!(f, "@{subpath}")?;
542 }
543 Ok(())
544 }
545 }
546 }
547}
548
549#[derive(Debug, Clone, PartialEq, Eq)]
550pub struct RenameRule {
551 pub from: ItemName,
552 pub to: ItemName,
553}
554
555#[derive(Debug, Clone, Default, PartialEq, Eq)]
557pub struct RenameMap(Vec<RenameRule>);
558
559impl RenameMap {
560 pub fn new() -> Self {
561 Self(Vec::new())
562 }
563
564 pub fn insert(&mut self, from: ItemName, to: ItemName) {
565 if let Some(existing) = self.0.iter_mut().find(|r| r.from == from) {
566 existing.to = to;
567 return;
568 }
569 self.0.push(RenameRule { from, to });
570 }
571 pub fn get(&self, from: &str) -> Option<&ItemName> {
572 self.0.iter().find(|r| r.from == from).map(|r| &r.to)
573 }
574}
575
576impl Serialize for RenameMap {
577 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
578 use serde::ser::SerializeMap;
579 let mut map = serializer.serialize_map(Some(self.0.len()))?;
580 for rule in &self.0 {
581 map.serialize_entry(rule.from.as_str(), rule.to.as_str())?;
582 }
583 map.end()
584 }
585}
586
587impl<'de> Deserialize<'de> for RenameMap {
588 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
589 let map = indexmap::IndexMap::<String, String>::deserialize(deserializer)?;
590 Ok(Self(
591 map.into_iter()
592 .map(|(from, to)| RenameRule {
593 from: ItemName::from(from),
594 to: ItemName::from(to),
595 })
596 .collect(),
597 ))
598 }
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604 use serde::{Deserialize, Serialize};
605 use std::path::PathBuf;
606
607 #[derive(Debug, Serialize, Deserialize, PartialEq)]
608 struct Wrapper<T> {
609 value: T,
610 }
611
612 #[test]
613 fn dest_path_roundtrip() {
614 let v = Wrapper {
615 value: DestPath::from("agents/coder.md"),
616 };
617 let s = toml::to_string(&v).unwrap();
618 let out: Wrapper<DestPath> = toml::from_str(&s).unwrap();
619 assert_eq!(v, out);
620 }
621
622 #[test]
623 fn rename_map_toml_roundtrip_compat() {
624 #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
625 struct RenameWrapper {
626 rename: RenameMap,
627 }
628
629 let input = r#"rename = { "coder" = "cool-coder" }"#;
630 let parsed: RenameWrapper = toml::from_str(input).unwrap();
631 assert_eq!(
632 parsed.rename.get("coder").map(|v| v.as_str()),
633 Some("cool-coder")
634 );
635
636 let serialized = toml::to_string(&parsed).unwrap();
637 let reparsed: RenameWrapper = toml::from_str(&serialized).unwrap();
638 assert_eq!(parsed, reparsed);
639 }
640
641 #[test]
642 fn source_subpath_normalizes_windows_and_unix_separators() {
643 let subpath = SourceSubpath::new(r"plugins\foo/bar\baz").unwrap();
644 assert_eq!(subpath.as_str(), "plugins/foo/bar/baz");
645 }
646
647 #[test]
648 fn source_subpath_and_dest_path_share_normalization_rules() {
649 let raw = r"./plugins\foo/bar\";
650 let subpath = SourceSubpath::new(raw).unwrap();
651 let dest = DestPath::new(raw).unwrap();
652
653 assert_eq!(subpath.as_str(), "plugins/foo/bar");
654 assert_eq!(dest.as_str(), "plugins/foo/bar");
655 assert_eq!(subpath.as_str(), dest.as_str());
656 }
657
658 #[test]
659 fn source_subpath_rejects_empty() {
660 let err = SourceSubpath::new("").unwrap_err();
661 assert_eq!(err, SourceSubpathError::Empty);
662 }
663
664 #[test]
665 fn source_subpath_rejects_absolute() {
666 let err = SourceSubpath::new("/abs/path").unwrap_err();
667 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
668 }
669
670 #[test]
671 fn source_subpath_rejects_root_only() {
672 let err = SourceSubpath::new("/").unwrap_err();
673 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
674 }
675
676 #[test]
677 fn source_subpath_rejects_windows_absolute() {
678 let err = SourceSubpath::new(r"C:\abs\path").unwrap_err();
679 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
680 }
681
682 #[test]
683 fn source_subpath_rejects_escape() {
684 let err = SourceSubpath::new("../escape").unwrap_err();
685 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
686 }
687
688 #[test]
689 fn source_subpath_accepts_nested_relative_path() {
690 let subpath = SourceSubpath::new("a/b/c").unwrap();
691 assert_eq!(subpath.as_str(), "a/b/c");
692 }
693
694 #[test]
695 fn source_subpath_accepts_plugins_foo() {
696 let subpath = SourceSubpath::new("plugins/foo").unwrap();
697 assert_eq!(subpath.as_str(), "plugins/foo");
698 }
699
700 #[test]
701 fn source_subpath_serializes_with_forward_slashes() {
702 #[derive(Debug, Serialize, Deserialize, PartialEq)]
703 struct SubpathWrapper {
704 subpath: SourceSubpath,
705 }
706
707 let wrapper = SubpathWrapper {
708 subpath: SourceSubpath::new(r"plugins\foo").unwrap(),
709 };
710 let toml = toml::to_string(&wrapper).unwrap();
711 assert!(toml.contains("subpath = \"plugins/foo\""));
712 }
713
714 #[test]
715 fn source_subpath_join_under_base() {
716 let base = PathBuf::from("/tmp/mars");
717 let subpath = SourceSubpath::new("plugins/foo").unwrap();
718 let joined = subpath.join_under(&base).unwrap();
719 assert_eq!(joined, base.join("plugins").join("foo"));
720 }
721
722 #[test]
723 fn source_subpath_join_under_rejects_escape_path() {
724 let escaped = SourceSubpath(String::from("../escape"));
725 let err = escaped.join_under(Path::new("/tmp/base")).unwrap_err();
726 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
727 }
728
729 #[test]
733 fn source_subpath_accepts_deeply_nested() {
734 let subpath = SourceSubpath::new("a/b/c/d/e").unwrap();
735 assert_eq!(subpath.as_str(), "a/b/c/d/e");
736 }
737
738 #[test]
740 fn source_subpath_rejects_windows_drive_forward_slash() {
741 let err = SourceSubpath::new("C:/foo").unwrap_err();
742 assert!(matches!(err, SourceSubpathError::Absolute { .. }));
743 }
744
745 #[test]
747 fn source_subpath_rejects_current_dir_dot() {
748 let err = SourceSubpath::new(".").unwrap_err();
749 assert_eq!(err, SourceSubpathError::Empty);
750 }
751
752 #[test]
753 fn dest_path_normalizes_windows_and_unix_separators() {
754 let path = DestPath::new(r"agents\foo/bar\baz.md").unwrap();
755 assert_eq!(path.as_str(), "agents/foo/bar/baz.md");
756 }
757
758 #[test]
759 fn dest_path_rejects_empty() {
760 let err = DestPath::new("").unwrap_err();
761 assert_eq!(err, DestPathError::Empty);
762 }
763
764 #[test]
765 fn dest_path_rejects_absolute() {
766 let err = DestPath::new("/abs/path").unwrap_err();
767 assert!(matches!(err, DestPathError::Absolute { .. }));
768 }
769
770 #[test]
771 fn dest_path_rejects_root_only() {
772 let err = DestPath::new("/").unwrap_err();
773 assert!(matches!(err, DestPathError::Absolute { .. }));
774 }
775
776 #[test]
777 fn dest_path_rejects_windows_absolute() {
778 let err = DestPath::new(r"C:\abs\path").unwrap_err();
779 assert!(matches!(err, DestPathError::Absolute { .. }));
780 }
781
782 #[test]
783 fn dest_path_rejects_windows_drive_relative() {
784 let err = DestPath::new("C:relative").unwrap_err();
785 assert!(matches!(err, DestPathError::Absolute { .. }));
786 }
787
788 #[test]
789 fn dest_path_rejects_escape() {
790 let err = DestPath::new("../escape").unwrap_err();
791 assert!(matches!(err, DestPathError::Escaping { .. }));
792 }
793
794 #[test]
795 fn dest_path_normalizes_trailing_slash() {
796 let path = DestPath::new("skills/planning/").unwrap();
797 assert_eq!(path.as_str(), "skills/planning");
798 }
799
800 #[test]
801 fn dest_path_normalizes_leading_dot_slash() {
802 let path = DestPath::new("./skills/planning").unwrap();
803 assert_eq!(path.as_str(), "skills/planning");
804 }
805
806 #[test]
807 fn dest_path_item_name_extracts_agent_leaf() {
808 let path = DestPath::new("agents/coder.md").unwrap();
809 assert_eq!(path.item_name(ItemKind::Agent), "coder");
810 }
811
812 #[test]
813 fn dest_path_item_name_extracts_skill_leaf() {
814 let path = DestPath::new("skills/planning").unwrap();
815 assert_eq!(path.item_name(ItemKind::Skill), "planning");
816 }
817
818 #[test]
819 fn dest_path_item_name_extracts_bootstrap_doc_container() {
820 let path = DestPath::new("bootstrap/global-auth/BOOTSTRAP.md").unwrap();
821 assert_eq!(path.item_name(ItemKind::BootstrapDoc), "global-auth");
822 }
823
824 #[test]
825 fn dest_path_item_name_extracts_nested_agent_leaf() {
826 let path = DestPath::new("agents/sub/deep.md").unwrap();
827 assert_eq!(path.item_name(ItemKind::Agent), "deep");
828 }
829
830 #[test]
831 fn dest_path_item_name_handles_no_slash_edge_case() {
832 let path = DestPath::new("solo.md").unwrap();
833 assert_eq!(path.item_name(ItemKind::Agent), "solo");
834 }
835
836 #[test]
839 fn source_subpath_rejects_mid_path_double_parent_escape() {
840 let err = SourceSubpath::new("a/../../escape").unwrap_err();
841 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
842 }
843
844 #[test]
847 fn source_subpath_rejects_harmless_parent_in_middle() {
848 let err = SourceSubpath::new("a/b/../c").unwrap_err();
849 assert!(matches!(err, SourceSubpathError::Escaping { .. }));
850 }
851
852 #[test]
854 fn source_subpath_normalizes_trailing_slash() {
855 let subpath = SourceSubpath::new("plugins/foo/").unwrap();
856 assert_eq!(subpath.as_str(), "plugins/foo");
857 }
858
859 #[test]
861 fn source_subpath_normalizes_leading_dot_slash() {
862 let subpath = SourceSubpath::new("./plugins/foo").unwrap();
863 assert_eq!(subpath.as_str(), "plugins/foo");
864 }
865
866 #[test]
868 fn source_subpath_join_under_base_with_trailing_slash() {
869 let base = PathBuf::from("/tmp/mars/");
870 let subpath = SourceSubpath::new("plugins/foo").unwrap();
871 let joined = subpath.join_under(&base).unwrap();
872 assert_eq!(joined, PathBuf::from("/tmp/mars/plugins/foo"));
874 }
875
876 #[test]
878 fn locked_source_json_roundtrip_without_subpath() {
879 let json = r#"{"url":"https://github.com/org/base.git"}"#;
880 let parsed: crate::lock::LockedSource = serde_json::from_str(json).unwrap();
881 assert!(parsed.subpath.is_none());
882 }
883
884 #[test]
886 fn locked_source_json_roundtrip_with_subpath() {
887 let source = crate::lock::LockedSource {
888 url: Some(SourceUrl::from("https://github.com/org/base.git")),
889 path: None,
890 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
891 version: None,
892 commit: None,
893 };
894 let json = serde_json::to_string(&source).unwrap();
895 assert!(json.contains("\"subpath\":\"plugins/foo\""));
896 let reparsed: crate::lock::LockedSource = serde_json::from_str(&json).unwrap();
897 assert_eq!(
898 reparsed.subpath.as_ref().map(SourceSubpath::as_str),
899 Some("plugins/foo")
900 );
901 }
902
903 #[test]
905 fn locked_source_toml_missing_subpath_field_is_none() {
906 let toml_str = r#"
907version = 1
908
909[dependencies.dep]
910url = "https://github.com/org/dep.git"
911commit = "deadbeef"
912"#;
913 let lock: crate::lock::LockFile = toml::from_str(toml_str).unwrap();
914 assert!(lock.dependencies["dep"].subpath.is_none());
915 }
916
917 #[test]
919 fn locked_source_toml_subpath_serializes_alongside_other_fields() {
920 let source = crate::lock::LockedSource {
921 url: Some(SourceUrl::from("https://github.com/org/base.git")),
922 path: None,
923 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
924 version: Some("v1.0.0".to_string()),
925 commit: Some(CommitHash::from("abc123")),
926 };
927 #[derive(Serialize)]
928 struct Wrapper {
929 source: crate::lock::LockedSource,
930 }
931 let serialized = toml::to_string(&Wrapper { source }).unwrap();
932 assert!(serialized.contains("subpath = \"plugins/foo\""));
933 assert!(serialized.contains("url = "));
934 assert!(serialized.contains("commit = "));
935 }
936
937 #[test]
938 fn lock_roundtrip_with_and_without_subpath() {
939 let old_lock = r#"
940version = 1
941
942[dependencies.base]
943url = "https://github.com/org/base.git"
944"#;
945 let parsed_old: crate::lock::LockFile = toml::from_str(old_lock).unwrap();
946 assert!(parsed_old.dependencies["base"].subpath.is_none());
947
948 let lock = crate::lock::LockFile {
949 version: 1,
950 dependencies: indexmap::IndexMap::from([(
951 SourceName::from("base"),
952 crate::lock::LockedSource {
953 url: Some(SourceUrl::from("https://github.com/org/base.git")),
954 path: None,
955 subpath: Some(SourceSubpath::new(r"plugins\foo").unwrap()),
956 version: Some("v1.2.3".to_string()),
957 commit: Some(CommitHash::from("abc123")),
958 },
959 )]),
960 items: indexmap::IndexMap::new(),
961 config_entries: std::collections::BTreeMap::new(),
962 dependency_model_aliases: indexmap::IndexMap::new(),
963 };
964 let serialized = toml::to_string_pretty(&lock).unwrap();
965 assert!(serialized.contains("subpath = \"plugins/foo\""));
966 let reparsed: crate::lock::LockFile = toml::from_str(&serialized).unwrap();
967 assert_eq!(
968 reparsed.dependencies["base"]
969 .subpath
970 .as_ref()
971 .map(SourceSubpath::as_str),
972 Some("plugins/foo")
973 );
974 }
975
976 #[test]
977 fn config_roundtrip_preserves_subpath() {
978 let config = r#"
979[dependencies.base]
980url = "https://github.com/org/base.git"
981subpath = "plugins\\foo"
982"#;
983 let parsed: crate::config::Config = toml::from_str(config).unwrap();
984 assert_eq!(
985 parsed.dependencies["base"]
986 .subpath
987 .as_ref()
988 .map(SourceSubpath::as_str),
989 Some("plugins/foo")
990 );
991
992 let serialized = toml::to_string(&parsed).unwrap();
993 assert!(serialized.contains("subpath = \"plugins/foo\""));
994 let reparsed: crate::config::Config = toml::from_str(&serialized).unwrap();
995 assert_eq!(
996 reparsed.dependencies["base"]
997 .subpath
998 .as_ref()
999 .map(SourceSubpath::as_str),
1000 Some("plugins/foo")
1001 );
1002 }
1003
1004 #[test]
1005 fn source_id_git_same_url_same_subpath_are_equal_and_hash_equal() {
1006 let a = SourceId::git_with_subpath(
1007 SourceUrl::from("https://example.com/repo.git"),
1008 Some(SourceSubpath::new("plugins/foo").unwrap()),
1009 );
1010 let b = SourceId::git_with_subpath(
1011 SourceUrl::from("https://example.com/repo.git"),
1012 Some(SourceSubpath::new("plugins/foo").unwrap()),
1013 );
1014
1015 assert_eq!(a, b);
1016
1017 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1018 a.hash(&mut hasher_a);
1019 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1020 b.hash(&mut hasher_b);
1021 assert_eq!(hasher_a.finish(), hasher_b.finish());
1022 }
1023
1024 #[test]
1025 fn source_id_git_same_url_different_subpaths_are_distinct() {
1026 let a = SourceId::git_with_subpath(
1027 SourceUrl::from("https://example.com/repo.git"),
1028 Some(SourceSubpath::new("plugins/foo").unwrap()),
1029 );
1030 let b = SourceId::git_with_subpath(
1031 SourceUrl::from("https://example.com/repo.git"),
1032 Some(SourceSubpath::new("plugins/bar").unwrap()),
1033 );
1034
1035 assert_ne!(a, b);
1036
1037 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1038 a.hash(&mut hasher_a);
1039 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1040 b.hash(&mut hasher_b);
1041 assert_ne!(hasher_a.finish(), hasher_b.finish());
1042 }
1043
1044 #[test]
1050 fn source_id_path_none_and_some_subpath_hash_distinctly() {
1051 let canonical = PathBuf::from("/tmp/my-repo");
1052 let a = SourceId::Path {
1053 canonical: canonical.clone(),
1054 subpath: None,
1055 };
1056 let b = SourceId::Path {
1057 canonical: canonical.clone(),
1058 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1059 };
1060
1061 assert_ne!(a, b);
1062
1063 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1064 a.hash(&mut hasher_a);
1065 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1066 b.hash(&mut hasher_b);
1067 assert_ne!(hasher_a.finish(), hasher_b.finish());
1068 }
1069
1070 #[test]
1073 fn source_id_path_same_canonical_same_subpath_are_equal() {
1074 let canonical = PathBuf::from("/tmp/my-repo");
1075 let a = SourceId::Path {
1076 canonical: canonical.clone(),
1077 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1078 };
1079 let b = SourceId::Path {
1080 canonical: canonical.clone(),
1081 subpath: 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]
1096 fn source_id_path_same_canonical_different_subpaths_are_distinct() {
1097 let canonical = PathBuf::from("/tmp/my-repo");
1098 let a = SourceId::Path {
1099 canonical: canonical.clone(),
1100 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1101 };
1102 let b = SourceId::Path {
1103 canonical: canonical.clone(),
1104 subpath: Some(SourceSubpath::new("plugins/bar").unwrap()),
1105 };
1106
1107 assert_ne!(a, b);
1108
1109 let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1110 a.hash(&mut hasher_a);
1111 let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1112 b.hash(&mut hasher_b);
1113 assert_ne!(hasher_a.finish(), hasher_b.finish());
1114 }
1115
1116 #[test]
1122 fn lock_write_and_load_roundtrip_preserves_subpath() {
1123 use crate::lock::{LockFile, LockedSource};
1124 use tempfile::TempDir;
1125
1126 let dir = TempDir::new().unwrap();
1127 let lock = LockFile {
1128 version: 1,
1129 dependencies: indexmap::IndexMap::from([(
1130 SourceName::from("dep"),
1131 LockedSource {
1132 url: Some(SourceUrl::from("https://github.com/org/repo.git")),
1133 path: None,
1134 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1135 version: Some("v1.2.3".to_string()),
1136 commit: Some(CommitHash::from("deadbeef")),
1137 },
1138 )]),
1139 items: indexmap::IndexMap::new(),
1140 config_entries: std::collections::BTreeMap::new(),
1141 dependency_model_aliases: indexmap::IndexMap::new(),
1142 };
1143
1144 crate::lock::write(dir.path(), &lock).unwrap();
1145 let loaded = crate::lock::load(dir.path()).unwrap();
1146
1147 assert_eq!(
1148 loaded.dependencies["dep"]
1149 .subpath
1150 .as_ref()
1151 .map(SourceSubpath::as_str),
1152 Some("plugins/foo")
1153 );
1154 assert_eq!(
1155 loaded.dependencies["dep"].url.as_deref(),
1156 Some("https://github.com/org/repo.git")
1157 );
1158 assert_eq!(
1159 loaded.dependencies["dep"].version.as_deref(),
1160 Some("v1.2.3")
1161 );
1162 }
1163
1164 #[test]
1170 fn effective_dependency_subpath_preserved_through_merge() {
1171 use crate::config::{Config, merge};
1172
1173 let toml_str = r#"
1174[dependencies.dep]
1175url = "https://github.com/org/repo.git"
1176subpath = "plugins/foo"
1177"#;
1178 let config: Config = toml::from_str(toml_str).unwrap();
1179 let effective = merge(config, crate::config::LocalConfig::default()).unwrap();
1180 assert_eq!(
1181 effective.dependencies["dep"]
1182 .subpath
1183 .as_ref()
1184 .map(SourceSubpath::as_str),
1185 Some("plugins/foo")
1186 );
1187 assert!(matches!(
1189 &effective.dependencies["dep"].source_id,
1190 SourceId::Git { subpath: Some(sp), .. } if sp.as_str() == "plugins/foo"
1191 ));
1192 }
1193}