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