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