Skip to main content

mars_agents/
types.rs

1use serde::{Deserialize, Serialize};
2use std::borrow::Borrow;
3use std::fmt;
4use std::hash::{Hash, Hasher};
5use std::ops::Deref;
6use std::path::{Component, Path, PathBuf};
7
8macro_rules! string_newtype {
9    ($(#[$meta:meta])* $name:ident) => {
10        $(#[$meta])*
11        #[derive(
12            Serialize, Deserialize, Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd,
13        )]
14        #[serde(transparent)]
15        pub struct $name(String);
16
17        impl $name {
18            pub fn new(value: impl Into<String>) -> Self {
19                Self(value.into())
20            }
21
22            pub fn as_str(&self) -> &str {
23                &self.0
24            }
25
26            pub fn into_inner(self) -> String {
27                self.0
28            }
29        }
30
31        impl From<String> for $name {
32            fn from(value: String) -> Self {
33                Self(value)
34            }
35        }
36
37        impl From<&str> for $name {
38            fn from(value: &str) -> Self {
39                Self(value.to_owned())
40            }
41        }
42
43        impl AsRef<str> for $name {
44            fn as_ref(&self) -> &str {
45                &self.0
46            }
47        }
48
49        impl Borrow<str> for $name {
50            fn borrow(&self) -> &str {
51                &self.0
52            }
53        }
54
55        impl Deref for $name {
56            type Target = str;
57
58            fn deref(&self) -> &Self::Target {
59                &self.0
60            }
61        }
62
63        impl From<$name> for String {
64            fn from(value: $name) -> Self {
65                value.0
66            }
67        }
68
69        impl fmt::Display for $name {
70            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71                f.write_str(&self.0)
72            }
73        }
74
75        impl PartialEq<str> for $name {
76            fn eq(&self, other: &str) -> bool {
77                self.0 == other
78            }
79        }
80
81        impl PartialEq<&str> for $name {
82            fn eq(&self, other: &&str) -> bool {
83                self.0 == *other
84            }
85        }
86
87        impl PartialEq<String> for $name {
88            fn eq(&self, other: &String) -> bool {
89                self.0 == *other
90            }
91        }
92
93        impl PartialEq<$name> for String {
94            fn eq(&self, other: &$name) -> bool {
95                *self == other.0
96            }
97        }
98    };
99}
100
101string_newtype!(SourceName);
102string_newtype!(ItemName);
103string_newtype!(SourceUrl);
104string_newtype!(CommitHash);
105string_newtype!(ContentHash);
106
107/// Shared path normalization result for relative path coordinates.
108enum NormalizeError {
109    Empty,
110    Absolute,
111    Escaping,
112}
113
114/// Normalize a relative path coordinate to forward-slash segments.
115/// Returns the normalized coordinate or a rejection reason.
116fn 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/// Normalized relative package coordinate under a fetched source root.
137#[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    /// Join this relative subpath under `base`, rejecting traversal attempts.
180    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/// Where an item came from — used for lock provenance and display.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub enum SourceOrigin {
283    /// From a dependency (git or path source).
284    Dependency(SourceName),
285    /// From the local project's [package] declaration.
286    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/// Kind of installable item.
299#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
300#[serde(rename_all = "lowercase")]
301pub enum ItemKind {
302    Agent,
303    Skill,
304}
305
306impl fmt::Display for ItemKind {
307    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308        match self {
309            ItemKind::Agent => write!(f, "agent"),
310            ItemKind::Skill => write!(f, "skill"),
311        }
312    }
313}
314
315/// Stable identity for an installed item — decoupled from source URL.
316///
317/// Items are identified by `(kind, name)`, not by source URL.
318/// If a package moves to a different git host, the item identity is preserved.
319#[derive(Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
320pub struct ItemId {
321    pub kind: ItemKind,
322    pub name: ItemName,
323}
324
325impl fmt::Display for ItemId {
326    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327        write!(f, "{}/{}", self.kind, self.name)
328    }
329}
330
331/// Normalized relative path coordinate (always forward-slash).
332/// Use `resolve(root)` to get a native filesystem path.
333#[derive(Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
334pub struct DestPath(String);
335
336impl DestPath {
337    /// Create from any string, normalizing separators and rejecting invalid paths.
338    pub fn new(value: impl AsRef<str>) -> Result<Self, DestPathError> {
339        let raw = value.as_ref();
340        if raw.is_empty() {
341            return Err(DestPathError::Empty);
342        }
343
344        let normalized_separators = raw.replace('\\', "/");
345        if is_windows_absolute(&normalized_separators)
346            || is_windows_drive_relative(&normalized_separators)
347        {
348            return Err(DestPathError::Absolute {
349                input: raw.to_string(),
350            });
351        }
352
353        let normalized = normalize_relative_coordinate(raw).map_err(|err| match err {
354            NormalizeError::Empty => DestPathError::Empty,
355            NormalizeError::Absolute => DestPathError::Absolute {
356                input: raw.to_string(),
357            },
358            NormalizeError::Escaping => DestPathError::Escaping {
359                input: raw.to_string(),
360            },
361        })?;
362
363        Ok(Self(normalized))
364    }
365
366    /// The normalized string representation.
367    pub fn as_str(&self) -> &str {
368        &self.0
369    }
370
371    /// Consume and return the inner string.
372    pub fn into_inner(self) -> String {
373        self.0
374    }
375
376    /// Resolve to a native filesystem path under the given root.
377    pub fn resolve(&self, root: &Path) -> PathBuf {
378        let mut result = root.to_path_buf();
379        for component in self.components() {
380            result.push(component);
381        }
382        result
383    }
384
385    /// Split into path components (by forward slash).
386    pub fn components(&self) -> impl Iterator<Item = &str> {
387        self.0.split('/')
388    }
389
390    /// Extract the installed item name from this path.
391    /// Agents strip a trailing `.md`; skills use the leaf directory name.
392    pub fn item_name(&self, kind: ItemKind) -> String {
393        let last = self.0.rsplit('/').next().unwrap_or("");
394        match kind {
395            ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
396            ItemKind::Skill => last.to_string(),
397        }
398    }
399
400    /// Create from a host-relative path by stripping a root prefix.
401    /// Used for CLI commands that accept filesystem paths.
402    pub fn from_host_relative(path: &Path, root: &Path) -> Result<Self, DestPathError> {
403        let relative = path
404            .strip_prefix(root)
405            .map_err(|_| DestPathError::ConversionFailed {
406                reason: format!("path {:?} is not under root {:?}", path, root),
407            })?;
408
409        let mut segments = Vec::new();
410        for component in relative.components() {
411            match component {
412                Component::Normal(seg) => segments.push(seg.to_string_lossy().into_owned()),
413                Component::CurDir => {}
414                Component::ParentDir => {
415                    return Err(DestPathError::Escaping {
416                        input: path.to_string_lossy().into_owned(),
417                    });
418                }
419                Component::RootDir | Component::Prefix(_) => {
420                    return Err(DestPathError::Absolute {
421                        input: path.to_string_lossy().into_owned(),
422                    });
423                }
424            }
425        }
426
427        if segments.is_empty() {
428            return Err(DestPathError::Empty);
429        }
430
431        Self::new(segments.join("/"))
432    }
433}
434
435impl From<&str> for DestPath {
436    fn from(value: &str) -> Self {
437        Self::new(value).expect("invalid destination path")
438    }
439}
440
441impl From<String> for DestPath {
442    fn from(value: String) -> Self {
443        Self::new(value).expect("invalid destination path")
444    }
445}
446
447impl AsRef<str> for DestPath {
448    fn as_ref(&self) -> &str {
449        &self.0
450    }
451}
452
453impl Borrow<str> for DestPath {
454    fn borrow(&self) -> &str {
455        &self.0
456    }
457}
458
459impl Hash for DestPath {
460    fn hash<H: Hasher>(&self, state: &mut H) {
461        self.0.hash(state);
462    }
463}
464
465impl fmt::Display for DestPath {
466    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
467        f.write_str(&self.0)
468    }
469}
470
471impl Serialize for DestPath {
472    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
473        self.0.serialize(serializer)
474    }
475}
476
477impl<'de> Deserialize<'de> for DestPath {
478    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
479        let value = String::deserialize(deserializer)?;
480        DestPath::new(value).map_err(serde::de::Error::custom)
481    }
482}
483
484/// Resolved context for a mars command — project root + managed output root.
485///
486/// Named fields prevent argument-order bugs that plague `(project_root, managed_root)` pairs.
487#[derive(Debug, Clone)]
488pub struct MarsContext {
489    /// Project root containing mars.toml and mars.lock.
490    pub project_root: PathBuf,
491    /// Managed output directory (e.g. /project/.agents).
492    pub managed_root: PathBuf,
493}
494
495#[cfg(test)]
496impl MarsContext {
497    /// Create a MarsContext for tests without any validation.
498    pub fn for_test(project_root: PathBuf, managed_root: PathBuf) -> Self {
499        MarsContext {
500            project_root,
501            managed_root,
502        }
503    }
504}
505
506/// Stable source identity used for resolver deduplication.
507#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd, Serialize, Deserialize)]
508pub enum SourceId {
509    Git {
510        url: SourceUrl,
511        #[serde(default, skip_serializing_if = "Option::is_none")]
512        subpath: Option<SourceSubpath>,
513    },
514    Path {
515        canonical: PathBuf,
516        #[serde(default, skip_serializing_if = "Option::is_none")]
517        subpath: Option<SourceSubpath>,
518    },
519}
520
521impl SourceId {
522    pub fn git(url: SourceUrl) -> Self {
523        Self::Git { url, subpath: None }
524    }
525
526    pub fn git_with_subpath(url: SourceUrl, subpath: Option<SourceSubpath>) -> Self {
527        Self::Git { url, subpath }
528    }
529
530    pub fn path(base: &Path, relative_or_absolute: &Path) -> std::io::Result<Self> {
531        Self::path_with_subpath(base, relative_or_absolute, None)
532    }
533
534    pub fn path_with_subpath(
535        base: &Path,
536        relative_or_absolute: &Path,
537        subpath: Option<SourceSubpath>,
538    ) -> std::io::Result<Self> {
539        let candidate = if relative_or_absolute.is_absolute() {
540            relative_or_absolute.to_path_buf()
541        } else {
542            base.join(relative_or_absolute)
543        };
544        let canonical = dunce::canonicalize(&candidate)?;
545        Ok(Self::Path { canonical, subpath })
546    }
547}
548
549impl fmt::Display for SourceId {
550    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
551        match self {
552            Self::Git { url, subpath } => {
553                write!(f, "git:{url}")?;
554                if let Some(subpath) = subpath {
555                    write!(f, "@{subpath}")?;
556                }
557                Ok(())
558            }
559            Self::Path { canonical, subpath } => {
560                write!(f, "path:{}", canonical.display())?;
561                if let Some(subpath) = subpath {
562                    write!(f, "@{subpath}")?;
563                }
564                Ok(())
565            }
566        }
567    }
568}
569
570#[derive(Debug, Clone, PartialEq, Eq)]
571pub struct RenameRule {
572    pub from: ItemName,
573    pub to: ItemName,
574}
575
576/// Ordered rename rules, serialized as TOML inline table/map for compatibility.
577#[derive(Debug, Clone, Default, PartialEq, Eq)]
578pub struct RenameMap(Vec<RenameRule>);
579
580impl RenameMap {
581    pub fn new() -> Self {
582        Self(Vec::new())
583    }
584
585    pub fn insert(&mut self, from: ItemName, to: ItemName) {
586        if let Some(existing) = self.0.iter_mut().find(|r| r.from == from) {
587            existing.to = to;
588            return;
589        }
590        self.0.push(RenameRule { from, to });
591    }
592
593    pub fn push(&mut self, rule: RenameRule) {
594        self.insert(rule.from, rule.to);
595    }
596
597    pub fn get(&self, from: &str) -> Option<&ItemName> {
598        self.0.iter().find(|r| r.from == from).map(|r| &r.to)
599    }
600
601    pub fn iter(&self) -> impl Iterator<Item = &RenameRule> {
602        self.0.iter()
603    }
604
605    pub fn is_empty(&self) -> bool {
606        self.0.is_empty()
607    }
608
609    pub fn len(&self) -> usize {
610        self.0.len()
611    }
612}
613
614impl Serialize for RenameMap {
615    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
616        use serde::ser::SerializeMap;
617        let mut map = serializer.serialize_map(Some(self.0.len()))?;
618        for rule in &self.0 {
619            map.serialize_entry(rule.from.as_str(), rule.to.as_str())?;
620        }
621        map.end()
622    }
623}
624
625impl<'de> Deserialize<'de> for RenameMap {
626    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
627        let map = indexmap::IndexMap::<String, String>::deserialize(deserializer)?;
628        Ok(Self(
629            map.into_iter()
630                .map(|(from, to)| RenameRule {
631                    from: ItemName::from(from),
632                    to: ItemName::from(to),
633                })
634                .collect(),
635        ))
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642    use serde::{Deserialize, Serialize};
643    use std::path::PathBuf;
644
645    #[derive(Debug, Serialize, Deserialize, PartialEq)]
646    struct Wrapper<T> {
647        value: T,
648    }
649
650    #[test]
651    fn dest_path_roundtrip() {
652        let v = Wrapper {
653            value: DestPath::from("agents/coder.md"),
654        };
655        let s = toml::to_string(&v).unwrap();
656        let out: Wrapper<DestPath> = toml::from_str(&s).unwrap();
657        assert_eq!(v, out);
658    }
659
660    #[test]
661    fn rename_map_toml_roundtrip_compat() {
662        #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
663        struct RenameWrapper {
664            rename: RenameMap,
665        }
666
667        let input = r#"rename = { "coder" = "cool-coder" }"#;
668        let parsed: RenameWrapper = toml::from_str(input).unwrap();
669        assert_eq!(
670            parsed.rename.get("coder").map(|v| v.as_str()),
671            Some("cool-coder")
672        );
673
674        let serialized = toml::to_string(&parsed).unwrap();
675        let reparsed: RenameWrapper = toml::from_str(&serialized).unwrap();
676        assert_eq!(parsed, reparsed);
677    }
678
679    #[test]
680    fn source_subpath_normalizes_windows_and_unix_separators() {
681        let subpath = SourceSubpath::new(r"plugins\foo/bar\baz").unwrap();
682        assert_eq!(subpath.as_str(), "plugins/foo/bar/baz");
683    }
684
685    #[test]
686    fn source_subpath_and_dest_path_share_normalization_rules() {
687        let raw = r"./plugins\foo/bar\";
688        let subpath = SourceSubpath::new(raw).unwrap();
689        let dest = DestPath::new(raw).unwrap();
690
691        assert_eq!(subpath.as_str(), "plugins/foo/bar");
692        assert_eq!(dest.as_str(), "plugins/foo/bar");
693        assert_eq!(subpath.as_str(), dest.as_str());
694    }
695
696    #[test]
697    fn source_subpath_rejects_empty() {
698        let err = SourceSubpath::new("").unwrap_err();
699        assert_eq!(err, SourceSubpathError::Empty);
700    }
701
702    #[test]
703    fn source_subpath_rejects_absolute() {
704        let err = SourceSubpath::new("/abs/path").unwrap_err();
705        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
706    }
707
708    #[test]
709    fn source_subpath_rejects_root_only() {
710        let err = SourceSubpath::new("/").unwrap_err();
711        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
712    }
713
714    #[test]
715    fn source_subpath_rejects_windows_absolute() {
716        let err = SourceSubpath::new(r"C:\abs\path").unwrap_err();
717        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
718    }
719
720    #[test]
721    fn source_subpath_rejects_escape() {
722        let err = SourceSubpath::new("../escape").unwrap_err();
723        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
724    }
725
726    #[test]
727    fn source_subpath_accepts_nested_relative_path() {
728        let subpath = SourceSubpath::new("a/b/c").unwrap();
729        assert_eq!(subpath.as_str(), "a/b/c");
730    }
731
732    #[test]
733    fn source_subpath_accepts_plugins_foo() {
734        let subpath = SourceSubpath::new("plugins/foo").unwrap();
735        assert_eq!(subpath.as_str(), "plugins/foo");
736    }
737
738    #[test]
739    fn source_subpath_serializes_with_forward_slashes() {
740        #[derive(Debug, Serialize, Deserialize, PartialEq)]
741        struct SubpathWrapper {
742            subpath: SourceSubpath,
743        }
744
745        let wrapper = SubpathWrapper {
746            subpath: SourceSubpath::new(r"plugins\foo").unwrap(),
747        };
748        let toml = toml::to_string(&wrapper).unwrap();
749        assert!(toml.contains("subpath = \"plugins/foo\""));
750    }
751
752    #[test]
753    fn source_subpath_join_under_base() {
754        let base = PathBuf::from("/tmp/mars");
755        let subpath = SourceSubpath::new("plugins/foo").unwrap();
756        let joined = subpath.join_under(&base).unwrap();
757        assert_eq!(joined, base.join("plugins").join("foo"));
758    }
759
760    #[test]
761    fn source_subpath_join_under_rejects_escape_path() {
762        let escaped = SourceSubpath(String::from("../escape"));
763        let err = escaped.join_under(Path::new("/tmp/base")).unwrap_err();
764        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
765    }
766
767    // --- Additional edge cases ---
768
769    // Edge case 4: deeply nested path (5 levels)
770    #[test]
771    fn source_subpath_accepts_deeply_nested() {
772        let subpath = SourceSubpath::new("a/b/c/d/e").unwrap();
773        assert_eq!(subpath.as_str(), "a/b/c/d/e");
774    }
775
776    // Edge case 7: Windows drive letter with forward slash (C:/foo)
777    #[test]
778    fn source_subpath_rejects_windows_drive_forward_slash() {
779        let err = SourceSubpath::new("C:/foo").unwrap_err();
780        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
781    }
782
783    // Edge case 9: "." alone — CurDir is skipped → segments empty → Empty error
784    #[test]
785    fn source_subpath_rejects_current_dir_dot() {
786        let err = SourceSubpath::new(".").unwrap_err();
787        assert_eq!(err, SourceSubpathError::Empty);
788    }
789
790    #[test]
791    fn dest_path_normalizes_windows_and_unix_separators() {
792        let path = DestPath::new(r"agents\foo/bar\baz.md").unwrap();
793        assert_eq!(path.as_str(), "agents/foo/bar/baz.md");
794    }
795
796    #[test]
797    fn dest_path_rejects_empty() {
798        let err = DestPath::new("").unwrap_err();
799        assert_eq!(err, DestPathError::Empty);
800    }
801
802    #[test]
803    fn dest_path_rejects_absolute() {
804        let err = DestPath::new("/abs/path").unwrap_err();
805        assert!(matches!(err, DestPathError::Absolute { .. }));
806    }
807
808    #[test]
809    fn dest_path_rejects_root_only() {
810        let err = DestPath::new("/").unwrap_err();
811        assert!(matches!(err, DestPathError::Absolute { .. }));
812    }
813
814    #[test]
815    fn dest_path_rejects_windows_absolute() {
816        let err = DestPath::new(r"C:\abs\path").unwrap_err();
817        assert!(matches!(err, DestPathError::Absolute { .. }));
818    }
819
820    #[test]
821    fn dest_path_rejects_windows_drive_relative() {
822        let err = DestPath::new("C:relative").unwrap_err();
823        assert!(matches!(err, DestPathError::Absolute { .. }));
824    }
825
826    #[test]
827    fn dest_path_rejects_escape() {
828        let err = DestPath::new("../escape").unwrap_err();
829        assert!(matches!(err, DestPathError::Escaping { .. }));
830    }
831
832    #[test]
833    fn dest_path_normalizes_trailing_slash() {
834        let path = DestPath::new("skills/planning/").unwrap();
835        assert_eq!(path.as_str(), "skills/planning");
836    }
837
838    #[test]
839    fn dest_path_normalizes_leading_dot_slash() {
840        let path = DestPath::new("./skills/planning").unwrap();
841        assert_eq!(path.as_str(), "skills/planning");
842    }
843
844    #[test]
845    fn dest_path_item_name_extracts_agent_leaf() {
846        let path = DestPath::new("agents/coder.md").unwrap();
847        assert_eq!(path.item_name(ItemKind::Agent), "coder");
848    }
849
850    #[test]
851    fn dest_path_item_name_extracts_skill_leaf() {
852        let path = DestPath::new("skills/planning").unwrap();
853        assert_eq!(path.item_name(ItemKind::Skill), "planning");
854    }
855
856    #[test]
857    fn dest_path_item_name_extracts_nested_agent_leaf() {
858        let path = DestPath::new("agents/sub/deep.md").unwrap();
859        assert_eq!(path.item_name(ItemKind::Agent), "deep");
860    }
861
862    #[test]
863    fn dest_path_item_name_handles_no_slash_edge_case() {
864        let path = DestPath::new("solo.md").unwrap();
865        assert_eq!(path.item_name(ItemKind::Agent), "solo");
866    }
867
868    // Edge case 11: mid-path parent escape "a/../../escape" — hits ParentDir immediately after
869    // pushing "a", so it is rejected as Escaping (conservative: any ".." rejected)
870    #[test]
871    fn source_subpath_rejects_mid_path_double_parent_escape() {
872        let err = SourceSubpath::new("a/../../escape").unwrap_err();
873        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
874    }
875
876    // Edge case 12: "a/b/../c" — conservative policy: any ".." is rejected as Escaping,
877    // even when logically harmless. This documents and pins the chosen policy.
878    #[test]
879    fn source_subpath_rejects_harmless_parent_in_middle() {
880        let err = SourceSubpath::new("a/b/../c").unwrap_err();
881        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
882    }
883
884    // Edge case 13: trailing slash normalizes (no trailing slash in canonical form)
885    #[test]
886    fn source_subpath_normalizes_trailing_slash() {
887        let subpath = SourceSubpath::new("plugins/foo/").unwrap();
888        assert_eq!(subpath.as_str(), "plugins/foo");
889    }
890
891    // Edge case 14: leading "./" normalizes to the bare path
892    #[test]
893    fn source_subpath_normalizes_leading_dot_slash() {
894        let subpath = SourceSubpath::new("./plugins/foo").unwrap();
895        assert_eq!(subpath.as_str(), "plugins/foo");
896    }
897
898    // join_under: base path with trailing slash (PathBuf handles it consistently)
899    #[test]
900    fn source_subpath_join_under_base_with_trailing_slash() {
901        let base = PathBuf::from("/tmp/mars/");
902        let subpath = SourceSubpath::new("plugins/foo").unwrap();
903        let joined = subpath.join_under(&base).unwrap();
904        // PathBuf normalizes trailing slash — result should be /tmp/mars/plugins/foo
905        assert_eq!(joined, PathBuf::from("/tmp/mars/plugins/foo"));
906    }
907
908    // JSON serde round-trip: LockedSource without subpath → subpath = None
909    #[test]
910    fn locked_source_json_roundtrip_without_subpath() {
911        let json = r#"{"url":"https://github.com/org/base.git"}"#;
912        let parsed: crate::lock::LockedSource = serde_json::from_str(json).unwrap();
913        assert!(parsed.subpath.is_none());
914    }
915
916    // JSON serde round-trip: LockedSource with subpath serializes as forward-slash string
917    #[test]
918    fn locked_source_json_roundtrip_with_subpath() {
919        let source = crate::lock::LockedSource {
920            url: Some(SourceUrl::from("https://github.com/org/base.git")),
921            path: None,
922            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
923            version: None,
924            commit: None,
925            tree_hash: None,
926        };
927        let json = serde_json::to_string(&source).unwrap();
928        assert!(json.contains("\"subpath\":\"plugins/foo\""));
929        let reparsed: crate::lock::LockedSource = serde_json::from_str(&json).unwrap();
930        assert_eq!(
931            reparsed.subpath.as_ref().map(SourceSubpath::as_str),
932            Some("plugins/foo")
933        );
934    }
935
936    // Backward compat: old lock TOML with no subpath field deserializes with subpath = None (RES-013)
937    #[test]
938    fn locked_source_toml_missing_subpath_field_is_none() {
939        let toml_str = r#"
940version = 1
941
942[dependencies.dep]
943url = "https://github.com/org/dep.git"
944commit = "deadbeef"
945"#;
946        let lock: crate::lock::LockFile = toml::from_str(toml_str).unwrap();
947        assert!(lock.dependencies["dep"].subpath.is_none());
948    }
949
950    // RES-014: LockedSource with subpath serializes the subpath field alongside other fields
951    #[test]
952    fn locked_source_toml_subpath_serializes_alongside_other_fields() {
953        let source = crate::lock::LockedSource {
954            url: Some(SourceUrl::from("https://github.com/org/base.git")),
955            path: None,
956            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
957            version: Some("v1.0.0".to_string()),
958            commit: Some(CommitHash::from("abc123")),
959            tree_hash: None,
960        };
961        #[derive(Serialize)]
962        struct Wrapper {
963            source: crate::lock::LockedSource,
964        }
965        let serialized = toml::to_string(&Wrapper { source }).unwrap();
966        assert!(serialized.contains("subpath = \"plugins/foo\""));
967        assert!(serialized.contains("url = "));
968        assert!(serialized.contains("commit = "));
969    }
970
971    #[test]
972    fn lock_roundtrip_with_and_without_subpath() {
973        let old_lock = r#"
974version = 1
975
976[dependencies.base]
977url = "https://github.com/org/base.git"
978"#;
979        let parsed_old: crate::lock::LockFile = toml::from_str(old_lock).unwrap();
980        assert!(parsed_old.dependencies["base"].subpath.is_none());
981
982        let lock = crate::lock::LockFile {
983            version: 1,
984            dependencies: indexmap::IndexMap::from([(
985                SourceName::from("base"),
986                crate::lock::LockedSource {
987                    url: Some(SourceUrl::from("https://github.com/org/base.git")),
988                    path: None,
989                    subpath: Some(SourceSubpath::new(r"plugins\foo").unwrap()),
990                    version: Some("v1.2.3".to_string()),
991                    commit: Some(CommitHash::from("abc123")),
992                    tree_hash: None,
993                },
994            )]),
995            items: indexmap::IndexMap::new(),
996        };
997        let serialized = toml::to_string_pretty(&lock).unwrap();
998        assert!(serialized.contains("subpath = \"plugins/foo\""));
999        let reparsed: crate::lock::LockFile = toml::from_str(&serialized).unwrap();
1000        assert_eq!(
1001            reparsed.dependencies["base"]
1002                .subpath
1003                .as_ref()
1004                .map(SourceSubpath::as_str),
1005            Some("plugins/foo")
1006        );
1007    }
1008
1009    #[test]
1010    fn config_roundtrip_preserves_subpath() {
1011        let config = r#"
1012[dependencies.base]
1013url = "https://github.com/org/base.git"
1014subpath = "plugins\\foo"
1015"#;
1016        let parsed: crate::config::Config = toml::from_str(config).unwrap();
1017        assert_eq!(
1018            parsed.dependencies["base"]
1019                .subpath
1020                .as_ref()
1021                .map(SourceSubpath::as_str),
1022            Some("plugins/foo")
1023        );
1024
1025        let serialized = toml::to_string(&parsed).unwrap();
1026        assert!(serialized.contains("subpath = \"plugins/foo\""));
1027        let reparsed: crate::config::Config = toml::from_str(&serialized).unwrap();
1028        assert_eq!(
1029            reparsed.dependencies["base"]
1030                .subpath
1031                .as_ref()
1032                .map(SourceSubpath::as_str),
1033            Some("plugins/foo")
1034        );
1035    }
1036
1037    #[test]
1038    fn source_id_git_same_url_same_subpath_are_equal_and_hash_equal() {
1039        let a = SourceId::git_with_subpath(
1040            SourceUrl::from("https://example.com/repo.git"),
1041            Some(SourceSubpath::new("plugins/foo").unwrap()),
1042        );
1043        let b = SourceId::git_with_subpath(
1044            SourceUrl::from("https://example.com/repo.git"),
1045            Some(SourceSubpath::new("plugins/foo").unwrap()),
1046        );
1047
1048        assert_eq!(a, b);
1049
1050        let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1051        a.hash(&mut hasher_a);
1052        let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1053        b.hash(&mut hasher_b);
1054        assert_eq!(hasher_a.finish(), hasher_b.finish());
1055    }
1056
1057    #[test]
1058    fn source_id_git_same_url_different_subpaths_are_distinct() {
1059        let a = SourceId::git_with_subpath(
1060            SourceUrl::from("https://example.com/repo.git"),
1061            Some(SourceSubpath::new("plugins/foo").unwrap()),
1062        );
1063        let b = SourceId::git_with_subpath(
1064            SourceUrl::from("https://example.com/repo.git"),
1065            Some(SourceSubpath::new("plugins/bar").unwrap()),
1066        );
1067
1068        assert_ne!(a, b);
1069
1070        let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1071        a.hash(&mut hasher_a);
1072        let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1073        b.hash(&mut hasher_b);
1074        assert_ne!(hasher_a.finish(), hasher_b.finish());
1075    }
1076
1077    // ========== RES-002: SourceId::Path hash stability with subpath ==========
1078
1079    /// RES-002: SourceId::Path with subpath=None and subpath=Some("plugins/foo")
1080    /// must hash to distinct values — same canonical path but different subpaths
1081    /// must not collide.
1082    #[test]
1083    fn source_id_path_none_and_some_subpath_hash_distinctly() {
1084        let canonical = PathBuf::from("/tmp/my-repo");
1085        let a = SourceId::Path {
1086            canonical: canonical.clone(),
1087            subpath: None,
1088        };
1089        let b = SourceId::Path {
1090            canonical: canonical.clone(),
1091            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1092        };
1093
1094        assert_ne!(a, b);
1095
1096        let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1097        a.hash(&mut hasher_a);
1098        let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1099        b.hash(&mut hasher_b);
1100        assert_ne!(hasher_a.finish(), hasher_b.finish());
1101    }
1102
1103    /// RES-002: Two SourceId::Path with the same canonical and same subpath must
1104    /// be equal and hash equally.
1105    #[test]
1106    fn source_id_path_same_canonical_same_subpath_are_equal() {
1107        let canonical = PathBuf::from("/tmp/my-repo");
1108        let a = SourceId::Path {
1109            canonical: canonical.clone(),
1110            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1111        };
1112        let b = SourceId::Path {
1113            canonical: canonical.clone(),
1114            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1115        };
1116
1117        assert_eq!(a, b);
1118
1119        let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1120        a.hash(&mut hasher_a);
1121        let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1122        b.hash(&mut hasher_b);
1123        assert_eq!(hasher_a.finish(), hasher_b.finish());
1124    }
1125
1126    /// RES-002: Two SourceId::Path with same canonical but different subpaths must
1127    /// not be equal and must hash differently.
1128    #[test]
1129    fn source_id_path_same_canonical_different_subpaths_are_distinct() {
1130        let canonical = PathBuf::from("/tmp/my-repo");
1131        let a = SourceId::Path {
1132            canonical: canonical.clone(),
1133            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1134        };
1135        let b = SourceId::Path {
1136            canonical: canonical.clone(),
1137            subpath: Some(SourceSubpath::new("plugins/bar").unwrap()),
1138        };
1139
1140        assert_ne!(a, b);
1141
1142        let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1143        a.hash(&mut hasher_a);
1144        let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1145        b.hash(&mut hasher_b);
1146        assert_ne!(hasher_a.finish(), hasher_b.finish());
1147    }
1148
1149    // ========== RES-001: lock file write + load round-trip via lock::write/load ==========
1150
1151    /// RES-001: A lock file written with lock::write and re-loaded with lock::load
1152    /// must preserve the subpath field exactly. This exercises the full atomic
1153    /// write path, not just toml::to_string.
1154    #[test]
1155    fn lock_write_and_load_roundtrip_preserves_subpath() {
1156        use crate::lock::{LockFile, LockedSource};
1157        use tempfile::TempDir;
1158
1159        let dir = TempDir::new().unwrap();
1160        let lock = LockFile {
1161            version: 1,
1162            dependencies: indexmap::IndexMap::from([(
1163                SourceName::from("dep"),
1164                LockedSource {
1165                    url: Some(SourceUrl::from("https://github.com/org/repo.git")),
1166                    path: None,
1167                    subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1168                    version: Some("v1.2.3".to_string()),
1169                    commit: Some(CommitHash::from("deadbeef")),
1170                    tree_hash: None,
1171                },
1172            )]),
1173            items: indexmap::IndexMap::new(),
1174        };
1175
1176        crate::lock::write(dir.path(), &lock).unwrap();
1177        let loaded = crate::lock::load(dir.path()).unwrap();
1178
1179        assert_eq!(
1180            loaded.dependencies["dep"]
1181                .subpath
1182                .as_ref()
1183                .map(SourceSubpath::as_str),
1184            Some("plugins/foo")
1185        );
1186        assert_eq!(
1187            loaded.dependencies["dep"].url.as_deref(),
1188            Some("https://github.com/org/repo.git")
1189        );
1190        assert_eq!(
1191            loaded.dependencies["dep"].version.as_deref(),
1192            Some("v1.2.3")
1193        );
1194    }
1195
1196    // ========== RES-001: EffectiveDependency carries subpath after merge ==========
1197
1198    /// RES-001 (config side): after merge_with_root the EffectiveDependency.subpath
1199    /// matches what was in the Config.  This confirms the subpath survives the
1200    /// config-load → merge step.
1201    #[test]
1202    fn effective_dependency_subpath_preserved_through_merge() {
1203        use crate::config::{Config, merge};
1204
1205        let toml_str = r#"
1206[dependencies.dep]
1207url = "https://github.com/org/repo.git"
1208subpath = "plugins/foo"
1209"#;
1210        let config: Config = toml::from_str(toml_str).unwrap();
1211        let effective = merge(config, crate::config::LocalConfig::default()).unwrap();
1212        assert_eq!(
1213            effective.dependencies["dep"]
1214                .subpath
1215                .as_ref()
1216                .map(SourceSubpath::as_str),
1217            Some("plugins/foo")
1218        );
1219        // SourceId must embed the same subpath
1220        assert!(matches!(
1221            &effective.dependencies["dep"].id,
1222            SourceId::Git { subpath: Some(sp), .. } if sp.as_str() == "plugins/foo"
1223        ));
1224    }
1225}