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    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/// Stable identity for an installed item — decoupled from source URL.
322///
323/// Items are identified by `(kind, name)`, not by source URL.
324/// If a package moves to a different git host, the item identity is preserved.
325#[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/// Normalized relative path coordinate (always forward-slash).
338/// Use `resolve(root)` to get a native filesystem path.
339#[derive(Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
340pub struct DestPath(String);
341
342impl DestPath {
343    /// Create from any string, normalizing separators and rejecting invalid paths.
344    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    /// The normalized string representation.
373    pub fn as_str(&self) -> &str {
374        &self.0
375    }
376
377    /// Consume and return the inner string.
378    pub fn into_inner(self) -> String {
379        self.0
380    }
381
382    /// Resolve to a native filesystem path under the given root.
383    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    /// Split into path components (by forward slash).
392    pub fn components(&self) -> impl Iterator<Item = &str> {
393        self.0.split('/')
394    }
395
396    /// Extract the installed item name from this path.
397    /// Agents strip a trailing `.md`; bootstrap docs use their containing directory
398    /// because their canonical path is `bootstrap/<name>/BOOTSTRAP.md`; all other
399    /// directory-based kinds use the leaf name.
400    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    /// Create from a host-relative path by stripping a root prefix.
420    /// Used for CLI commands that accept filesystem paths.
421    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/// Resolved context for a mars command — project root + managed output root.
504///
505/// Named fields prevent argument-order bugs that plague `(project_root, managed_root)` pairs.
506#[derive(Debug, Clone)]
507pub struct MarsContext {
508    /// Project root containing mars.toml and mars.lock.
509    pub project_root: PathBuf,
510    /// Managed output directory (legacy/explicit target, e.g. /project/.claude).
511    pub managed_root: PathBuf,
512    /// Whether mars is running under Meridian management.
513    ///
514    /// Captured at context construction time so compilation decisions are stable
515    /// for the duration of a sync.
516    pub meridian_managed: bool,
517}
518
519#[cfg(test)]
520impl MarsContext {
521    /// Create a MarsContext for tests without any validation.
522    pub fn for_test(project_root: PathBuf, managed_root: PathBuf) -> Self {
523        MarsContext {
524            project_root,
525            managed_root,
526            meridian_managed: meridian_managed_from_env(),
527        }
528    }
529}
530
531pub fn meridian_managed_from_env() -> bool {
532    std::env::var("MERIDIAN_MANAGED").is_ok_and(|value| value == "1")
533}
534
535/// Stable source identity used for resolver deduplication.
536#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd, Serialize, Deserialize)]
537pub enum SourceId {
538    Git {
539        url: SourceUrl,
540        #[serde(default, skip_serializing_if = "Option::is_none")]
541        subpath: Option<SourceSubpath>,
542    },
543    Path {
544        canonical: PathBuf,
545        #[serde(default, skip_serializing_if = "Option::is_none")]
546        subpath: Option<SourceSubpath>,
547    },
548}
549
550impl SourceId {
551    pub fn git(url: SourceUrl) -> Self {
552        Self::Git { url, subpath: None }
553    }
554
555    pub fn git_with_subpath(url: SourceUrl, subpath: Option<SourceSubpath>) -> Self {
556        Self::Git { url, subpath }
557    }
558
559    pub fn path(base: &Path, relative_or_absolute: &Path) -> std::io::Result<Self> {
560        Self::path_with_subpath(base, relative_or_absolute, None)
561    }
562
563    pub fn path_with_subpath(
564        base: &Path,
565        relative_or_absolute: &Path,
566        subpath: Option<SourceSubpath>,
567    ) -> std::io::Result<Self> {
568        let candidate = if relative_or_absolute.is_absolute() {
569            relative_or_absolute.to_path_buf()
570        } else {
571            base.join(relative_or_absolute)
572        };
573        let canonical = dunce::canonicalize(&candidate)?;
574        Ok(Self::Path { canonical, subpath })
575    }
576}
577
578impl fmt::Display for SourceId {
579    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
580        match self {
581            Self::Git { url, subpath } => {
582                write!(f, "git:{url}")?;
583                if let Some(subpath) = subpath {
584                    write!(f, "@{subpath}")?;
585                }
586                Ok(())
587            }
588            Self::Path { canonical, subpath } => {
589                write!(f, "path:{}", canonical.display())?;
590                if let Some(subpath) = subpath {
591                    write!(f, "@{subpath}")?;
592                }
593                Ok(())
594            }
595        }
596    }
597}
598
599#[derive(Debug, Clone, PartialEq, Eq)]
600pub struct RenameRule {
601    pub from: ItemName,
602    pub to: ItemName,
603}
604
605/// Ordered rename rules, serialized as TOML inline table/map for compatibility.
606#[derive(Debug, Clone, Default, PartialEq, Eq)]
607pub struct RenameMap(Vec<RenameRule>);
608
609impl RenameMap {
610    pub fn new() -> Self {
611        Self(Vec::new())
612    }
613
614    pub fn insert(&mut self, from: ItemName, to: ItemName) {
615        if let Some(existing) = self.0.iter_mut().find(|r| r.from == from) {
616            existing.to = to;
617            return;
618        }
619        self.0.push(RenameRule { from, to });
620    }
621
622    pub fn push(&mut self, rule: RenameRule) {
623        self.insert(rule.from, rule.to);
624    }
625
626    pub fn get(&self, from: &str) -> Option<&ItemName> {
627        self.0.iter().find(|r| r.from == from).map(|r| &r.to)
628    }
629
630    pub fn iter(&self) -> impl Iterator<Item = &RenameRule> {
631        self.0.iter()
632    }
633
634    pub fn is_empty(&self) -> bool {
635        self.0.is_empty()
636    }
637
638    pub fn len(&self) -> usize {
639        self.0.len()
640    }
641}
642
643impl Serialize for RenameMap {
644    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
645        use serde::ser::SerializeMap;
646        let mut map = serializer.serialize_map(Some(self.0.len()))?;
647        for rule in &self.0 {
648            map.serialize_entry(rule.from.as_str(), rule.to.as_str())?;
649        }
650        map.end()
651    }
652}
653
654impl<'de> Deserialize<'de> for RenameMap {
655    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
656        let map = indexmap::IndexMap::<String, String>::deserialize(deserializer)?;
657        Ok(Self(
658            map.into_iter()
659                .map(|(from, to)| RenameRule {
660                    from: ItemName::from(from),
661                    to: ItemName::from(to),
662                })
663                .collect(),
664        ))
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    use serde::{Deserialize, Serialize};
672    use std::path::PathBuf;
673
674    #[derive(Debug, Serialize, Deserialize, PartialEq)]
675    struct Wrapper<T> {
676        value: T,
677    }
678
679    #[test]
680    fn dest_path_roundtrip() {
681        let v = Wrapper {
682            value: DestPath::from("agents/coder.md"),
683        };
684        let s = toml::to_string(&v).unwrap();
685        let out: Wrapper<DestPath> = toml::from_str(&s).unwrap();
686        assert_eq!(v, out);
687    }
688
689    #[test]
690    fn rename_map_toml_roundtrip_compat() {
691        #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
692        struct RenameWrapper {
693            rename: RenameMap,
694        }
695
696        let input = r#"rename = { "coder" = "cool-coder" }"#;
697        let parsed: RenameWrapper = toml::from_str(input).unwrap();
698        assert_eq!(
699            parsed.rename.get("coder").map(|v| v.as_str()),
700            Some("cool-coder")
701        );
702
703        let serialized = toml::to_string(&parsed).unwrap();
704        let reparsed: RenameWrapper = toml::from_str(&serialized).unwrap();
705        assert_eq!(parsed, reparsed);
706    }
707
708    #[test]
709    fn source_subpath_normalizes_windows_and_unix_separators() {
710        let subpath = SourceSubpath::new(r"plugins\foo/bar\baz").unwrap();
711        assert_eq!(subpath.as_str(), "plugins/foo/bar/baz");
712    }
713
714    #[test]
715    fn source_subpath_and_dest_path_share_normalization_rules() {
716        let raw = r"./plugins\foo/bar\";
717        let subpath = SourceSubpath::new(raw).unwrap();
718        let dest = DestPath::new(raw).unwrap();
719
720        assert_eq!(subpath.as_str(), "plugins/foo/bar");
721        assert_eq!(dest.as_str(), "plugins/foo/bar");
722        assert_eq!(subpath.as_str(), dest.as_str());
723    }
724
725    #[test]
726    fn source_subpath_rejects_empty() {
727        let err = SourceSubpath::new("").unwrap_err();
728        assert_eq!(err, SourceSubpathError::Empty);
729    }
730
731    #[test]
732    fn source_subpath_rejects_absolute() {
733        let err = SourceSubpath::new("/abs/path").unwrap_err();
734        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
735    }
736
737    #[test]
738    fn source_subpath_rejects_root_only() {
739        let err = SourceSubpath::new("/").unwrap_err();
740        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
741    }
742
743    #[test]
744    fn source_subpath_rejects_windows_absolute() {
745        let err = SourceSubpath::new(r"C:\abs\path").unwrap_err();
746        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
747    }
748
749    #[test]
750    fn source_subpath_rejects_escape() {
751        let err = SourceSubpath::new("../escape").unwrap_err();
752        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
753    }
754
755    #[test]
756    fn source_subpath_accepts_nested_relative_path() {
757        let subpath = SourceSubpath::new("a/b/c").unwrap();
758        assert_eq!(subpath.as_str(), "a/b/c");
759    }
760
761    #[test]
762    fn source_subpath_accepts_plugins_foo() {
763        let subpath = SourceSubpath::new("plugins/foo").unwrap();
764        assert_eq!(subpath.as_str(), "plugins/foo");
765    }
766
767    #[test]
768    fn source_subpath_serializes_with_forward_slashes() {
769        #[derive(Debug, Serialize, Deserialize, PartialEq)]
770        struct SubpathWrapper {
771            subpath: SourceSubpath,
772        }
773
774        let wrapper = SubpathWrapper {
775            subpath: SourceSubpath::new(r"plugins\foo").unwrap(),
776        };
777        let toml = toml::to_string(&wrapper).unwrap();
778        assert!(toml.contains("subpath = \"plugins/foo\""));
779    }
780
781    #[test]
782    fn source_subpath_join_under_base() {
783        let base = PathBuf::from("/tmp/mars");
784        let subpath = SourceSubpath::new("plugins/foo").unwrap();
785        let joined = subpath.join_under(&base).unwrap();
786        assert_eq!(joined, base.join("plugins").join("foo"));
787    }
788
789    #[test]
790    fn source_subpath_join_under_rejects_escape_path() {
791        let escaped = SourceSubpath(String::from("../escape"));
792        let err = escaped.join_under(Path::new("/tmp/base")).unwrap_err();
793        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
794    }
795
796    // --- Additional edge cases ---
797
798    // Edge case 4: deeply nested path (5 levels)
799    #[test]
800    fn source_subpath_accepts_deeply_nested() {
801        let subpath = SourceSubpath::new("a/b/c/d/e").unwrap();
802        assert_eq!(subpath.as_str(), "a/b/c/d/e");
803    }
804
805    // Edge case 7: Windows drive letter with forward slash (C:/foo)
806    #[test]
807    fn source_subpath_rejects_windows_drive_forward_slash() {
808        let err = SourceSubpath::new("C:/foo").unwrap_err();
809        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
810    }
811
812    // Edge case 9: "." alone — CurDir is skipped → segments empty → Empty error
813    #[test]
814    fn source_subpath_rejects_current_dir_dot() {
815        let err = SourceSubpath::new(".").unwrap_err();
816        assert_eq!(err, SourceSubpathError::Empty);
817    }
818
819    #[test]
820    fn dest_path_normalizes_windows_and_unix_separators() {
821        let path = DestPath::new(r"agents\foo/bar\baz.md").unwrap();
822        assert_eq!(path.as_str(), "agents/foo/bar/baz.md");
823    }
824
825    #[test]
826    fn dest_path_rejects_empty() {
827        let err = DestPath::new("").unwrap_err();
828        assert_eq!(err, DestPathError::Empty);
829    }
830
831    #[test]
832    fn dest_path_rejects_absolute() {
833        let err = DestPath::new("/abs/path").unwrap_err();
834        assert!(matches!(err, DestPathError::Absolute { .. }));
835    }
836
837    #[test]
838    fn dest_path_rejects_root_only() {
839        let err = DestPath::new("/").unwrap_err();
840        assert!(matches!(err, DestPathError::Absolute { .. }));
841    }
842
843    #[test]
844    fn dest_path_rejects_windows_absolute() {
845        let err = DestPath::new(r"C:\abs\path").unwrap_err();
846        assert!(matches!(err, DestPathError::Absolute { .. }));
847    }
848
849    #[test]
850    fn dest_path_rejects_windows_drive_relative() {
851        let err = DestPath::new("C:relative").unwrap_err();
852        assert!(matches!(err, DestPathError::Absolute { .. }));
853    }
854
855    #[test]
856    fn dest_path_rejects_escape() {
857        let err = DestPath::new("../escape").unwrap_err();
858        assert!(matches!(err, DestPathError::Escaping { .. }));
859    }
860
861    #[test]
862    fn dest_path_normalizes_trailing_slash() {
863        let path = DestPath::new("skills/planning/").unwrap();
864        assert_eq!(path.as_str(), "skills/planning");
865    }
866
867    #[test]
868    fn dest_path_normalizes_leading_dot_slash() {
869        let path = DestPath::new("./skills/planning").unwrap();
870        assert_eq!(path.as_str(), "skills/planning");
871    }
872
873    #[test]
874    fn dest_path_item_name_extracts_agent_leaf() {
875        let path = DestPath::new("agents/coder.md").unwrap();
876        assert_eq!(path.item_name(ItemKind::Agent), "coder");
877    }
878
879    #[test]
880    fn dest_path_item_name_extracts_skill_leaf() {
881        let path = DestPath::new("skills/planning").unwrap();
882        assert_eq!(path.item_name(ItemKind::Skill), "planning");
883    }
884
885    #[test]
886    fn dest_path_item_name_extracts_bootstrap_doc_container() {
887        let path = DestPath::new("bootstrap/global-auth/BOOTSTRAP.md").unwrap();
888        assert_eq!(path.item_name(ItemKind::BootstrapDoc), "global-auth");
889    }
890
891    #[test]
892    fn dest_path_item_name_extracts_nested_agent_leaf() {
893        let path = DestPath::new("agents/sub/deep.md").unwrap();
894        assert_eq!(path.item_name(ItemKind::Agent), "deep");
895    }
896
897    #[test]
898    fn dest_path_item_name_handles_no_slash_edge_case() {
899        let path = DestPath::new("solo.md").unwrap();
900        assert_eq!(path.item_name(ItemKind::Agent), "solo");
901    }
902
903    // Edge case 11: mid-path parent escape "a/../../escape" — hits ParentDir immediately after
904    // pushing "a", so it is rejected as Escaping (conservative: any ".." rejected)
905    #[test]
906    fn source_subpath_rejects_mid_path_double_parent_escape() {
907        let err = SourceSubpath::new("a/../../escape").unwrap_err();
908        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
909    }
910
911    // Edge case 12: "a/b/../c" — conservative policy: any ".." is rejected as Escaping,
912    // even when logically harmless. This documents and pins the chosen policy.
913    #[test]
914    fn source_subpath_rejects_harmless_parent_in_middle() {
915        let err = SourceSubpath::new("a/b/../c").unwrap_err();
916        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
917    }
918
919    // Edge case 13: trailing slash normalizes (no trailing slash in canonical form)
920    #[test]
921    fn source_subpath_normalizes_trailing_slash() {
922        let subpath = SourceSubpath::new("plugins/foo/").unwrap();
923        assert_eq!(subpath.as_str(), "plugins/foo");
924    }
925
926    // Edge case 14: leading "./" normalizes to the bare path
927    #[test]
928    fn source_subpath_normalizes_leading_dot_slash() {
929        let subpath = SourceSubpath::new("./plugins/foo").unwrap();
930        assert_eq!(subpath.as_str(), "plugins/foo");
931    }
932
933    // join_under: base path with trailing slash (PathBuf handles it consistently)
934    #[test]
935    fn source_subpath_join_under_base_with_trailing_slash() {
936        let base = PathBuf::from("/tmp/mars/");
937        let subpath = SourceSubpath::new("plugins/foo").unwrap();
938        let joined = subpath.join_under(&base).unwrap();
939        // PathBuf normalizes trailing slash — result should be /tmp/mars/plugins/foo
940        assert_eq!(joined, PathBuf::from("/tmp/mars/plugins/foo"));
941    }
942
943    // JSON serde round-trip: LockedSource without subpath → subpath = None
944    #[test]
945    fn locked_source_json_roundtrip_without_subpath() {
946        let json = r#"{"url":"https://github.com/org/base.git"}"#;
947        let parsed: crate::lock::LockedSource = serde_json::from_str(json).unwrap();
948        assert!(parsed.subpath.is_none());
949    }
950
951    // JSON serde round-trip: LockedSource with subpath serializes as forward-slash string
952    #[test]
953    fn locked_source_json_roundtrip_with_subpath() {
954        let source = crate::lock::LockedSource {
955            url: Some(SourceUrl::from("https://github.com/org/base.git")),
956            path: None,
957            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
958            version: None,
959            commit: None,
960            tree_hash: None,
961        };
962        let json = serde_json::to_string(&source).unwrap();
963        assert!(json.contains("\"subpath\":\"plugins/foo\""));
964        let reparsed: crate::lock::LockedSource = serde_json::from_str(&json).unwrap();
965        assert_eq!(
966            reparsed.subpath.as_ref().map(SourceSubpath::as_str),
967            Some("plugins/foo")
968        );
969    }
970
971    // Backward compat: old lock TOML with no subpath field deserializes with subpath = None (RES-013)
972    #[test]
973    fn locked_source_toml_missing_subpath_field_is_none() {
974        let toml_str = r#"
975version = 1
976
977[dependencies.dep]
978url = "https://github.com/org/dep.git"
979commit = "deadbeef"
980"#;
981        let lock: crate::lock::LockFile = toml::from_str(toml_str).unwrap();
982        assert!(lock.dependencies["dep"].subpath.is_none());
983    }
984
985    // RES-014: LockedSource with subpath serializes the subpath field alongside other fields
986    #[test]
987    fn locked_source_toml_subpath_serializes_alongside_other_fields() {
988        let source = crate::lock::LockedSource {
989            url: Some(SourceUrl::from("https://github.com/org/base.git")),
990            path: None,
991            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
992            version: Some("v1.0.0".to_string()),
993            commit: Some(CommitHash::from("abc123")),
994            tree_hash: None,
995        };
996        #[derive(Serialize)]
997        struct Wrapper {
998            source: crate::lock::LockedSource,
999        }
1000        let serialized = toml::to_string(&Wrapper { source }).unwrap();
1001        assert!(serialized.contains("subpath = \"plugins/foo\""));
1002        assert!(serialized.contains("url = "));
1003        assert!(serialized.contains("commit = "));
1004    }
1005
1006    #[test]
1007    fn lock_roundtrip_with_and_without_subpath() {
1008        let old_lock = r#"
1009version = 1
1010
1011[dependencies.base]
1012url = "https://github.com/org/base.git"
1013"#;
1014        let parsed_old: crate::lock::LockFile = toml::from_str(old_lock).unwrap();
1015        assert!(parsed_old.dependencies["base"].subpath.is_none());
1016
1017        let lock = crate::lock::LockFile {
1018            version: 1,
1019            dependencies: indexmap::IndexMap::from([(
1020                SourceName::from("base"),
1021                crate::lock::LockedSource {
1022                    url: Some(SourceUrl::from("https://github.com/org/base.git")),
1023                    path: None,
1024                    subpath: Some(SourceSubpath::new(r"plugins\foo").unwrap()),
1025                    version: Some("v1.2.3".to_string()),
1026                    commit: Some(CommitHash::from("abc123")),
1027                    tree_hash: None,
1028                },
1029            )]),
1030            items: indexmap::IndexMap::new(),
1031            config_entries: std::collections::BTreeMap::new(),
1032        };
1033        let serialized = toml::to_string_pretty(&lock).unwrap();
1034        assert!(serialized.contains("subpath = \"plugins/foo\""));
1035        let reparsed: crate::lock::LockFile = toml::from_str(&serialized).unwrap();
1036        assert_eq!(
1037            reparsed.dependencies["base"]
1038                .subpath
1039                .as_ref()
1040                .map(SourceSubpath::as_str),
1041            Some("plugins/foo")
1042        );
1043    }
1044
1045    #[test]
1046    fn config_roundtrip_preserves_subpath() {
1047        let config = r#"
1048[dependencies.base]
1049url = "https://github.com/org/base.git"
1050subpath = "plugins\\foo"
1051"#;
1052        let parsed: crate::config::Config = toml::from_str(config).unwrap();
1053        assert_eq!(
1054            parsed.dependencies["base"]
1055                .subpath
1056                .as_ref()
1057                .map(SourceSubpath::as_str),
1058            Some("plugins/foo")
1059        );
1060
1061        let serialized = toml::to_string(&parsed).unwrap();
1062        assert!(serialized.contains("subpath = \"plugins/foo\""));
1063        let reparsed: crate::config::Config = toml::from_str(&serialized).unwrap();
1064        assert_eq!(
1065            reparsed.dependencies["base"]
1066                .subpath
1067                .as_ref()
1068                .map(SourceSubpath::as_str),
1069            Some("plugins/foo")
1070        );
1071    }
1072
1073    #[test]
1074    fn source_id_git_same_url_same_subpath_are_equal_and_hash_equal() {
1075        let a = SourceId::git_with_subpath(
1076            SourceUrl::from("https://example.com/repo.git"),
1077            Some(SourceSubpath::new("plugins/foo").unwrap()),
1078        );
1079        let b = SourceId::git_with_subpath(
1080            SourceUrl::from("https://example.com/repo.git"),
1081            Some(SourceSubpath::new("plugins/foo").unwrap()),
1082        );
1083
1084        assert_eq!(a, b);
1085
1086        let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1087        a.hash(&mut hasher_a);
1088        let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1089        b.hash(&mut hasher_b);
1090        assert_eq!(hasher_a.finish(), hasher_b.finish());
1091    }
1092
1093    #[test]
1094    fn source_id_git_same_url_different_subpaths_are_distinct() {
1095        let a = SourceId::git_with_subpath(
1096            SourceUrl::from("https://example.com/repo.git"),
1097            Some(SourceSubpath::new("plugins/foo").unwrap()),
1098        );
1099        let b = SourceId::git_with_subpath(
1100            SourceUrl::from("https://example.com/repo.git"),
1101            Some(SourceSubpath::new("plugins/bar").unwrap()),
1102        );
1103
1104        assert_ne!(a, b);
1105
1106        let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1107        a.hash(&mut hasher_a);
1108        let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1109        b.hash(&mut hasher_b);
1110        assert_ne!(hasher_a.finish(), hasher_b.finish());
1111    }
1112
1113    // ========== RES-002: SourceId::Path hash stability with subpath ==========
1114
1115    /// RES-002: SourceId::Path with subpath=None and subpath=Some("plugins/foo")
1116    /// must hash to distinct values — same canonical path but different subpaths
1117    /// must not collide.
1118    #[test]
1119    fn source_id_path_none_and_some_subpath_hash_distinctly() {
1120        let canonical = PathBuf::from("/tmp/my-repo");
1121        let a = SourceId::Path {
1122            canonical: canonical.clone(),
1123            subpath: None,
1124        };
1125        let b = SourceId::Path {
1126            canonical: canonical.clone(),
1127            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1128        };
1129
1130        assert_ne!(a, b);
1131
1132        let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1133        a.hash(&mut hasher_a);
1134        let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1135        b.hash(&mut hasher_b);
1136        assert_ne!(hasher_a.finish(), hasher_b.finish());
1137    }
1138
1139    /// RES-002: Two SourceId::Path with the same canonical and same subpath must
1140    /// be equal and hash equally.
1141    #[test]
1142    fn source_id_path_same_canonical_same_subpath_are_equal() {
1143        let canonical = PathBuf::from("/tmp/my-repo");
1144        let a = SourceId::Path {
1145            canonical: canonical.clone(),
1146            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1147        };
1148        let b = SourceId::Path {
1149            canonical: canonical.clone(),
1150            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1151        };
1152
1153        assert_eq!(a, b);
1154
1155        let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1156        a.hash(&mut hasher_a);
1157        let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1158        b.hash(&mut hasher_b);
1159        assert_eq!(hasher_a.finish(), hasher_b.finish());
1160    }
1161
1162    /// RES-002: Two SourceId::Path with same canonical but different subpaths must
1163    /// not be equal and must hash differently.
1164    #[test]
1165    fn source_id_path_same_canonical_different_subpaths_are_distinct() {
1166        let canonical = PathBuf::from("/tmp/my-repo");
1167        let a = SourceId::Path {
1168            canonical: canonical.clone(),
1169            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1170        };
1171        let b = SourceId::Path {
1172            canonical: canonical.clone(),
1173            subpath: Some(SourceSubpath::new("plugins/bar").unwrap()),
1174        };
1175
1176        assert_ne!(a, b);
1177
1178        let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
1179        a.hash(&mut hasher_a);
1180        let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
1181        b.hash(&mut hasher_b);
1182        assert_ne!(hasher_a.finish(), hasher_b.finish());
1183    }
1184
1185    // ========== RES-001: lock file write + load round-trip via lock::write/load ==========
1186
1187    /// RES-001: A lock file written with lock::write and re-loaded with lock::load
1188    /// must preserve the subpath field exactly. This exercises the full atomic
1189    /// write path, not just toml::to_string.
1190    #[test]
1191    fn lock_write_and_load_roundtrip_preserves_subpath() {
1192        use crate::lock::{LockFile, LockedSource};
1193        use tempfile::TempDir;
1194
1195        let dir = TempDir::new().unwrap();
1196        let lock = LockFile {
1197            version: 1,
1198            dependencies: indexmap::IndexMap::from([(
1199                SourceName::from("dep"),
1200                LockedSource {
1201                    url: Some(SourceUrl::from("https://github.com/org/repo.git")),
1202                    path: None,
1203                    subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1204                    version: Some("v1.2.3".to_string()),
1205                    commit: Some(CommitHash::from("deadbeef")),
1206                    tree_hash: None,
1207                },
1208            )]),
1209            items: indexmap::IndexMap::new(),
1210            config_entries: std::collections::BTreeMap::new(),
1211        };
1212
1213        crate::lock::write(dir.path(), &lock).unwrap();
1214        let loaded = crate::lock::load(dir.path()).unwrap();
1215
1216        assert_eq!(
1217            loaded.dependencies["dep"]
1218                .subpath
1219                .as_ref()
1220                .map(SourceSubpath::as_str),
1221            Some("plugins/foo")
1222        );
1223        assert_eq!(
1224            loaded.dependencies["dep"].url.as_deref(),
1225            Some("https://github.com/org/repo.git")
1226        );
1227        assert_eq!(
1228            loaded.dependencies["dep"].version.as_deref(),
1229            Some("v1.2.3")
1230        );
1231    }
1232
1233    // ========== RES-001: EffectiveDependency carries subpath after merge ==========
1234
1235    /// RES-001 (config side): after merge_with_root the EffectiveDependency.subpath
1236    /// matches what was in the Config.  This confirms the subpath survives the
1237    /// config-load → merge step.
1238    #[test]
1239    fn effective_dependency_subpath_preserved_through_merge() {
1240        use crate::config::{Config, merge};
1241
1242        let toml_str = r#"
1243[dependencies.dep]
1244url = "https://github.com/org/repo.git"
1245subpath = "plugins/foo"
1246"#;
1247        let config: Config = toml::from_str(toml_str).unwrap();
1248        let effective = merge(config, crate::config::LocalConfig::default()).unwrap();
1249        assert_eq!(
1250            effective.dependencies["dep"]
1251                .subpath
1252                .as_ref()
1253                .map(SourceSubpath::as_str),
1254            Some("plugins/foo")
1255        );
1256        // SourceId must embed the same subpath
1257        assert!(matches!(
1258            &effective.dependencies["dep"].id,
1259            SourceId::Git { subpath: Some(sp), .. } if sp.as_str() == "plugins/foo"
1260        ));
1261    }
1262}