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/// Normalized relative package coordinate under a fetched source root.
108#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
109pub struct SourceSubpath(String);
110
111impl SourceSubpath {
112    pub fn new(value: impl AsRef<str>) -> Result<Self, SourceSubpathError> {
113        let raw = value.as_ref();
114        if raw.is_empty() {
115            return Err(SourceSubpathError::Empty);
116        }
117
118        let normalized_separators = raw.replace('\\', "/");
119        if is_windows_absolute(&normalized_separators) {
120            return Err(SourceSubpathError::Absolute {
121                input: raw.to_string(),
122            });
123        }
124
125        let mut segments = Vec::new();
126        for component in Path::new(&normalized_separators).components() {
127            match component {
128                Component::Normal(seg) => segments.push(seg.to_string_lossy().into_owned()),
129                Component::CurDir => {}
130                Component::ParentDir => {
131                    return Err(SourceSubpathError::Escaping {
132                        input: raw.to_string(),
133                    });
134                }
135                Component::RootDir | Component::Prefix(_) => {
136                    return Err(SourceSubpathError::Absolute {
137                        input: raw.to_string(),
138                    });
139                }
140            }
141        }
142
143        if segments.is_empty() {
144            return Err(SourceSubpathError::Empty);
145        }
146
147        Ok(Self(segments.join("/")))
148    }
149
150    pub fn as_str(&self) -> &str {
151        &self.0
152    }
153
154    pub fn as_path(&self) -> &Path {
155        Path::new(&self.0)
156    }
157
158    pub fn into_inner(self) -> String {
159        self.0
160    }
161
162    /// Join this relative subpath under `base`, rejecting traversal attempts.
163    pub fn join_under(&self, base: &Path) -> Result<PathBuf, SourceSubpathError> {
164        let mut joined = base.to_path_buf();
165        for component in self.as_path().components() {
166            match component {
167                Component::Normal(seg) => joined.push(seg),
168                Component::CurDir => {}
169                Component::ParentDir => {
170                    return Err(SourceSubpathError::Escaping {
171                        input: self.0.clone(),
172                    });
173                }
174                Component::RootDir | Component::Prefix(_) => {
175                    return Err(SourceSubpathError::Absolute {
176                        input: self.0.clone(),
177                    });
178                }
179            }
180        }
181
182        if joined.strip_prefix(base).is_err() {
183            return Err(SourceSubpathError::Escaping {
184                input: self.0.clone(),
185            });
186        }
187
188        Ok(joined)
189    }
190}
191
192impl fmt::Display for SourceSubpath {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        f.write_str(&self.0)
195    }
196}
197
198impl AsRef<str> for SourceSubpath {
199    fn as_ref(&self) -> &str {
200        self.as_str()
201    }
202}
203
204impl std::str::FromStr for SourceSubpath {
205    type Err = SourceSubpathError;
206
207    fn from_str(s: &str) -> Result<Self, Self::Err> {
208        Self::new(s)
209    }
210}
211
212impl Serialize for SourceSubpath {
213    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
214        self.0.serialize(serializer)
215    }
216}
217
218impl<'de> Deserialize<'de> for SourceSubpath {
219    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
220        let value = String::deserialize(deserializer)?;
221        SourceSubpath::new(value).map_err(serde::de::Error::custom)
222    }
223}
224
225#[derive(Debug, thiserror::Error, PartialEq, Eq)]
226pub enum SourceSubpathError {
227    #[error("subpath cannot be empty")]
228    Empty,
229    #[error("subpath must be relative, got absolute value: {input:?}")]
230    Absolute { input: String },
231    #[error("subpath cannot escape package root: {input:?}")]
232    Escaping { input: String },
233}
234
235fn is_windows_absolute(path: &str) -> bool {
236    let bytes = path.as_bytes();
237    if path.starts_with('/') {
238        return true;
239    }
240    if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
241        return true;
242    }
243    false
244}
245
246/// Where an item came from — used for lock provenance and display.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum SourceOrigin {
249    /// From a dependency (git or path source).
250    Dependency(SourceName),
251    /// From the local project's [package] declaration.
252    LocalPackage,
253}
254
255impl fmt::Display for SourceOrigin {
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        match self {
258            Self::Dependency(name) => write!(f, "{name}"),
259            Self::LocalPackage => write!(f, "_self"),
260        }
261    }
262}
263
264/// Kind of installable item.
265#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
266#[serde(rename_all = "lowercase")]
267pub enum ItemKind {
268    Agent,
269    Skill,
270}
271
272impl fmt::Display for ItemKind {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        match self {
275            ItemKind::Agent => write!(f, "agent"),
276            ItemKind::Skill => write!(f, "skill"),
277        }
278    }
279}
280
281/// Stable identity for an installed item — decoupled from source URL.
282///
283/// Items are identified by `(kind, name)`, not by source URL.
284/// If a package moves to a different git host, the item identity is preserved.
285#[derive(Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
286pub struct ItemId {
287    pub kind: ItemKind,
288    pub name: ItemName,
289}
290
291impl fmt::Display for ItemId {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        write!(f, "{}/{}", self.kind, self.name)
294    }
295}
296
297/// Relative path under the install root (`.agents/` / project root).
298#[derive(Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
299pub struct DestPath(PathBuf);
300
301impl DestPath {
302    pub fn new(value: impl Into<PathBuf>) -> Self {
303        Self(value.into())
304    }
305
306    pub fn as_path(&self) -> &Path {
307        &self.0
308    }
309
310    pub fn into_inner(self) -> PathBuf {
311        self.0
312    }
313
314    /// Resolve this relative path under a root path.
315    pub fn resolve(&self, root: &Path) -> PathBuf {
316        root.join(&self.0)
317    }
318}
319
320impl From<PathBuf> for DestPath {
321    fn from(value: PathBuf) -> Self {
322        Self(value)
323    }
324}
325
326impl From<&Path> for DestPath {
327    fn from(value: &Path) -> Self {
328        Self(value.to_path_buf())
329    }
330}
331
332impl From<&str> for DestPath {
333    fn from(value: &str) -> Self {
334        Self(PathBuf::from(value))
335    }
336}
337
338impl From<String> for DestPath {
339    fn from(value: String) -> Self {
340        Self(PathBuf::from(value))
341    }
342}
343
344impl AsRef<Path> for DestPath {
345    fn as_ref(&self) -> &Path {
346        &self.0
347    }
348}
349
350impl Borrow<Path> for DestPath {
351    fn borrow(&self) -> &Path {
352        &self.0
353    }
354}
355
356impl Borrow<str> for DestPath {
357    fn borrow(&self) -> &str {
358        self.0.to_str().expect("DestPath must be valid UTF-8")
359    }
360}
361
362impl Hash for DestPath {
363    fn hash<H: Hasher>(&self, state: &mut H) {
364        self.0.to_string_lossy().hash(state);
365    }
366}
367
368impl Deref for DestPath {
369    type Target = Path;
370
371    fn deref(&self) -> &Self::Target {
372        &self.0
373    }
374}
375
376impl fmt::Display for DestPath {
377    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
378        write!(f, "{}", self.0.display())
379    }
380}
381
382impl Serialize for DestPath {
383    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
384        self.0.to_string_lossy().serialize(serializer)
385    }
386}
387
388impl<'de> Deserialize<'de> for DestPath {
389    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
390        String::deserialize(deserializer).map(|s| Self(PathBuf::from(s)))
391    }
392}
393
394/// Resolved context for a mars command — project root + managed output root.
395///
396/// Named fields prevent argument-order bugs that plague `(project_root, managed_root)` pairs.
397#[derive(Debug, Clone)]
398pub struct MarsContext {
399    /// Project root containing mars.toml and mars.lock.
400    pub project_root: PathBuf,
401    /// Managed output directory (e.g. /project/.agents).
402    pub managed_root: PathBuf,
403}
404
405#[cfg(test)]
406impl MarsContext {
407    /// Create a MarsContext for tests without any validation.
408    pub fn for_test(project_root: PathBuf, managed_root: PathBuf) -> Self {
409        MarsContext {
410            project_root,
411            managed_root,
412        }
413    }
414}
415
416/// Stable source identity used for resolver deduplication.
417#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd, Serialize, Deserialize)]
418pub enum SourceId {
419    Git {
420        url: SourceUrl,
421        #[serde(default, skip_serializing_if = "Option::is_none")]
422        subpath: Option<SourceSubpath>,
423    },
424    Path {
425        canonical: PathBuf,
426        #[serde(default, skip_serializing_if = "Option::is_none")]
427        subpath: Option<SourceSubpath>,
428    },
429}
430
431impl SourceId {
432    pub fn git(url: SourceUrl) -> Self {
433        Self::Git { url, subpath: None }
434    }
435
436    pub fn git_with_subpath(url: SourceUrl, subpath: Option<SourceSubpath>) -> Self {
437        Self::Git { url, subpath }
438    }
439
440    pub fn path(base: &Path, relative_or_absolute: &Path) -> std::io::Result<Self> {
441        Self::path_with_subpath(base, relative_or_absolute, None)
442    }
443
444    pub fn path_with_subpath(
445        base: &Path,
446        relative_or_absolute: &Path,
447        subpath: Option<SourceSubpath>,
448    ) -> std::io::Result<Self> {
449        let candidate = if relative_or_absolute.is_absolute() {
450            relative_or_absolute.to_path_buf()
451        } else {
452            base.join(relative_or_absolute)
453        };
454        let canonical = candidate.canonicalize()?;
455        Ok(Self::Path { canonical, subpath })
456    }
457}
458
459impl fmt::Display for SourceId {
460    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461        match self {
462            Self::Git { url, subpath } => {
463                write!(f, "git:{url}")?;
464                if let Some(subpath) = subpath {
465                    write!(f, "@{subpath}")?;
466                }
467                Ok(())
468            }
469            Self::Path { canonical, subpath } => {
470                write!(f, "path:{}", canonical.display())?;
471                if let Some(subpath) = subpath {
472                    write!(f, "@{subpath}")?;
473                }
474                Ok(())
475            }
476        }
477    }
478}
479
480#[derive(Debug, Clone, PartialEq, Eq)]
481pub struct RenameRule {
482    pub from: ItemName,
483    pub to: ItemName,
484}
485
486/// Ordered rename rules, serialized as TOML inline table/map for compatibility.
487#[derive(Debug, Clone, Default, PartialEq, Eq)]
488pub struct RenameMap(Vec<RenameRule>);
489
490impl RenameMap {
491    pub fn new() -> Self {
492        Self(Vec::new())
493    }
494
495    pub fn insert(&mut self, from: ItemName, to: ItemName) {
496        if let Some(existing) = self.0.iter_mut().find(|r| r.from == from) {
497            existing.to = to;
498            return;
499        }
500        self.0.push(RenameRule { from, to });
501    }
502
503    pub fn push(&mut self, rule: RenameRule) {
504        self.insert(rule.from, rule.to);
505    }
506
507    pub fn get(&self, from: &str) -> Option<&ItemName> {
508        self.0.iter().find(|r| r.from == from).map(|r| &r.to)
509    }
510
511    pub fn iter(&self) -> impl Iterator<Item = &RenameRule> {
512        self.0.iter()
513    }
514
515    pub fn is_empty(&self) -> bool {
516        self.0.is_empty()
517    }
518
519    pub fn len(&self) -> usize {
520        self.0.len()
521    }
522}
523
524impl Serialize for RenameMap {
525    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
526        use serde::ser::SerializeMap;
527        let mut map = serializer.serialize_map(Some(self.0.len()))?;
528        for rule in &self.0 {
529            map.serialize_entry(rule.from.as_str(), rule.to.as_str())?;
530        }
531        map.end()
532    }
533}
534
535impl<'de> Deserialize<'de> for RenameMap {
536    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
537        let map = indexmap::IndexMap::<String, String>::deserialize(deserializer)?;
538        Ok(Self(
539            map.into_iter()
540                .map(|(from, to)| RenameRule {
541                    from: ItemName::from(from),
542                    to: ItemName::from(to),
543                })
544                .collect(),
545        ))
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use serde::{Deserialize, Serialize};
553    use std::path::PathBuf;
554
555    #[derive(Debug, Serialize, Deserialize, PartialEq)]
556    struct Wrapper<T> {
557        value: T,
558    }
559
560    #[test]
561    fn dest_path_roundtrip() {
562        let v = Wrapper {
563            value: DestPath::from("agents/coder.md"),
564        };
565        let s = toml::to_string(&v).unwrap();
566        let out: Wrapper<DestPath> = toml::from_str(&s).unwrap();
567        assert_eq!(v, out);
568    }
569
570    #[test]
571    fn rename_map_toml_roundtrip_compat() {
572        #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
573        struct RenameWrapper {
574            rename: RenameMap,
575        }
576
577        let input = r#"rename = { "coder" = "cool-coder" }"#;
578        let parsed: RenameWrapper = toml::from_str(input).unwrap();
579        assert_eq!(
580            parsed.rename.get("coder").map(|v| v.as_str()),
581            Some("cool-coder")
582        );
583
584        let serialized = toml::to_string(&parsed).unwrap();
585        let reparsed: RenameWrapper = toml::from_str(&serialized).unwrap();
586        assert_eq!(parsed, reparsed);
587    }
588
589    #[test]
590    fn source_subpath_normalizes_windows_and_unix_separators() {
591        let subpath = SourceSubpath::new(r"plugins\foo/bar\baz").unwrap();
592        assert_eq!(subpath.as_str(), "plugins/foo/bar/baz");
593    }
594
595    #[test]
596    fn source_subpath_rejects_empty() {
597        let err = SourceSubpath::new("").unwrap_err();
598        assert_eq!(err, SourceSubpathError::Empty);
599    }
600
601    #[test]
602    fn source_subpath_rejects_absolute() {
603        let err = SourceSubpath::new("/abs/path").unwrap_err();
604        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
605    }
606
607    #[test]
608    fn source_subpath_rejects_root_only() {
609        let err = SourceSubpath::new("/").unwrap_err();
610        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
611    }
612
613    #[test]
614    fn source_subpath_rejects_windows_absolute() {
615        let err = SourceSubpath::new(r"C:\abs\path").unwrap_err();
616        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
617    }
618
619    #[test]
620    fn source_subpath_rejects_escape() {
621        let err = SourceSubpath::new("../escape").unwrap_err();
622        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
623    }
624
625    #[test]
626    fn source_subpath_accepts_nested_relative_path() {
627        let subpath = SourceSubpath::new("a/b/c").unwrap();
628        assert_eq!(subpath.as_str(), "a/b/c");
629    }
630
631    #[test]
632    fn source_subpath_accepts_plugins_foo() {
633        let subpath = SourceSubpath::new("plugins/foo").unwrap();
634        assert_eq!(subpath.as_str(), "plugins/foo");
635    }
636
637    #[test]
638    fn source_subpath_serializes_with_forward_slashes() {
639        #[derive(Debug, Serialize, Deserialize, PartialEq)]
640        struct SubpathWrapper {
641            subpath: SourceSubpath,
642        }
643
644        let wrapper = SubpathWrapper {
645            subpath: SourceSubpath::new(r"plugins\foo").unwrap(),
646        };
647        let toml = toml::to_string(&wrapper).unwrap();
648        assert!(toml.contains("subpath = \"plugins/foo\""));
649    }
650
651    #[test]
652    fn source_subpath_join_under_base() {
653        let base = PathBuf::from("/tmp/mars");
654        let subpath = SourceSubpath::new("plugins/foo").unwrap();
655        let joined = subpath.join_under(&base).unwrap();
656        assert_eq!(joined, base.join("plugins").join("foo"));
657    }
658
659    #[test]
660    fn source_subpath_join_under_rejects_escape_path() {
661        let escaped = SourceSubpath(String::from("../escape"));
662        let err = escaped.join_under(Path::new("/tmp/base")).unwrap_err();
663        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
664    }
665
666    // --- Additional edge cases ---
667
668    // Edge case 4: deeply nested path (5 levels)
669    #[test]
670    fn source_subpath_accepts_deeply_nested() {
671        let subpath = SourceSubpath::new("a/b/c/d/e").unwrap();
672        assert_eq!(subpath.as_str(), "a/b/c/d/e");
673    }
674
675    // Edge case 7: Windows drive letter with forward slash (C:/foo)
676    #[test]
677    fn source_subpath_rejects_windows_drive_forward_slash() {
678        let err = SourceSubpath::new("C:/foo").unwrap_err();
679        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
680    }
681
682    // Edge case 9: "." alone — CurDir is skipped → segments empty → Empty error
683    #[test]
684    fn source_subpath_rejects_current_dir_dot() {
685        let err = SourceSubpath::new(".").unwrap_err();
686        assert_eq!(err, SourceSubpathError::Empty);
687    }
688
689    // Edge case 11: mid-path parent escape "a/../../escape" — hits ParentDir immediately after
690    // pushing "a", so it is rejected as Escaping (conservative: any ".." rejected)
691    #[test]
692    fn source_subpath_rejects_mid_path_double_parent_escape() {
693        let err = SourceSubpath::new("a/../../escape").unwrap_err();
694        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
695    }
696
697    // Edge case 12: "a/b/../c" — conservative policy: any ".." is rejected as Escaping,
698    // even when logically harmless. This documents and pins the chosen policy.
699    #[test]
700    fn source_subpath_rejects_harmless_parent_in_middle() {
701        let err = SourceSubpath::new("a/b/../c").unwrap_err();
702        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
703    }
704
705    // Edge case 13: trailing slash normalizes (no trailing slash in canonical form)
706    #[test]
707    fn source_subpath_normalizes_trailing_slash() {
708        let subpath = SourceSubpath::new("plugins/foo/").unwrap();
709        assert_eq!(subpath.as_str(), "plugins/foo");
710    }
711
712    // Edge case 14: leading "./" normalizes to the bare path
713    #[test]
714    fn source_subpath_normalizes_leading_dot_slash() {
715        let subpath = SourceSubpath::new("./plugins/foo").unwrap();
716        assert_eq!(subpath.as_str(), "plugins/foo");
717    }
718
719    // join_under: base path with trailing slash (PathBuf handles it consistently)
720    #[test]
721    fn source_subpath_join_under_base_with_trailing_slash() {
722        let base = PathBuf::from("/tmp/mars/");
723        let subpath = SourceSubpath::new("plugins/foo").unwrap();
724        let joined = subpath.join_under(&base).unwrap();
725        // PathBuf normalizes trailing slash — result should be /tmp/mars/plugins/foo
726        assert_eq!(joined, PathBuf::from("/tmp/mars/plugins/foo"));
727    }
728
729    // JSON serde round-trip: LockedSource without subpath → subpath = None
730    #[test]
731    fn locked_source_json_roundtrip_without_subpath() {
732        let json = r#"{"url":"https://github.com/org/base.git"}"#;
733        let parsed: crate::lock::LockedSource = serde_json::from_str(json).unwrap();
734        assert!(parsed.subpath.is_none());
735    }
736
737    // JSON serde round-trip: LockedSource with subpath serializes as forward-slash string
738    #[test]
739    fn locked_source_json_roundtrip_with_subpath() {
740        let source = crate::lock::LockedSource {
741            url: Some(SourceUrl::from("https://github.com/org/base.git")),
742            path: None,
743            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
744            version: None,
745            commit: None,
746            tree_hash: None,
747        };
748        let json = serde_json::to_string(&source).unwrap();
749        assert!(json.contains("\"subpath\":\"plugins/foo\""));
750        let reparsed: crate::lock::LockedSource = serde_json::from_str(&json).unwrap();
751        assert_eq!(
752            reparsed.subpath.as_ref().map(SourceSubpath::as_str),
753            Some("plugins/foo")
754        );
755    }
756
757    // Backward compat: old lock TOML with no subpath field deserializes with subpath = None (RES-013)
758    #[test]
759    fn locked_source_toml_missing_subpath_field_is_none() {
760        let toml_str = r#"
761version = 1
762
763[dependencies.dep]
764url = "https://github.com/org/dep.git"
765commit = "deadbeef"
766"#;
767        let lock: crate::lock::LockFile = toml::from_str(toml_str).unwrap();
768        assert!(lock.dependencies["dep"].subpath.is_none());
769    }
770
771    // RES-014: LockedSource with subpath serializes the subpath field alongside other fields
772    #[test]
773    fn locked_source_toml_subpath_serializes_alongside_other_fields() {
774        let source = crate::lock::LockedSource {
775            url: Some(SourceUrl::from("https://github.com/org/base.git")),
776            path: None,
777            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
778            version: Some("v1.0.0".to_string()),
779            commit: Some(CommitHash::from("abc123")),
780            tree_hash: None,
781        };
782        #[derive(Serialize)]
783        struct Wrapper {
784            source: crate::lock::LockedSource,
785        }
786        let serialized = toml::to_string(&Wrapper { source }).unwrap();
787        assert!(serialized.contains("subpath = \"plugins/foo\""));
788        assert!(serialized.contains("url = "));
789        assert!(serialized.contains("commit = "));
790    }
791
792    #[test]
793    fn lock_roundtrip_with_and_without_subpath() {
794        let old_lock = r#"
795version = 1
796
797[dependencies.base]
798url = "https://github.com/org/base.git"
799"#;
800        let parsed_old: crate::lock::LockFile = toml::from_str(old_lock).unwrap();
801        assert!(parsed_old.dependencies["base"].subpath.is_none());
802
803        let lock = crate::lock::LockFile {
804            version: 1,
805            dependencies: indexmap::IndexMap::from([(
806                SourceName::from("base"),
807                crate::lock::LockedSource {
808                    url: Some(SourceUrl::from("https://github.com/org/base.git")),
809                    path: None,
810                    subpath: Some(SourceSubpath::new(r"plugins\foo").unwrap()),
811                    version: Some("v1.2.3".to_string()),
812                    commit: Some(CommitHash::from("abc123")),
813                    tree_hash: None,
814                },
815            )]),
816            items: indexmap::IndexMap::new(),
817        };
818        let serialized = toml::to_string_pretty(&lock).unwrap();
819        assert!(serialized.contains("subpath = \"plugins/foo\""));
820        let reparsed: crate::lock::LockFile = toml::from_str(&serialized).unwrap();
821        assert_eq!(
822            reparsed.dependencies["base"]
823                .subpath
824                .as_ref()
825                .map(SourceSubpath::as_str),
826            Some("plugins/foo")
827        );
828    }
829
830    #[test]
831    fn config_roundtrip_preserves_subpath() {
832        let config = r#"
833[dependencies.base]
834url = "https://github.com/org/base.git"
835subpath = "plugins\\foo"
836"#;
837        let parsed: crate::config::Config = toml::from_str(config).unwrap();
838        assert_eq!(
839            parsed.dependencies["base"]
840                .subpath
841                .as_ref()
842                .map(SourceSubpath::as_str),
843            Some("plugins/foo")
844        );
845
846        let serialized = toml::to_string(&parsed).unwrap();
847        assert!(serialized.contains("subpath = \"plugins/foo\""));
848        let reparsed: crate::config::Config = toml::from_str(&serialized).unwrap();
849        assert_eq!(
850            reparsed.dependencies["base"]
851                .subpath
852                .as_ref()
853                .map(SourceSubpath::as_str),
854            Some("plugins/foo")
855        );
856    }
857
858    #[test]
859    fn source_id_git_same_url_same_subpath_are_equal_and_hash_equal() {
860        let a = SourceId::git_with_subpath(
861            SourceUrl::from("https://example.com/repo.git"),
862            Some(SourceSubpath::new("plugins/foo").unwrap()),
863        );
864        let b = SourceId::git_with_subpath(
865            SourceUrl::from("https://example.com/repo.git"),
866            Some(SourceSubpath::new("plugins/foo").unwrap()),
867        );
868
869        assert_eq!(a, b);
870
871        let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
872        a.hash(&mut hasher_a);
873        let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
874        b.hash(&mut hasher_b);
875        assert_eq!(hasher_a.finish(), hasher_b.finish());
876    }
877
878    #[test]
879    fn source_id_git_same_url_different_subpaths_are_distinct() {
880        let a = SourceId::git_with_subpath(
881            SourceUrl::from("https://example.com/repo.git"),
882            Some(SourceSubpath::new("plugins/foo").unwrap()),
883        );
884        let b = SourceId::git_with_subpath(
885            SourceUrl::from("https://example.com/repo.git"),
886            Some(SourceSubpath::new("plugins/bar").unwrap()),
887        );
888
889        assert_ne!(a, b);
890
891        let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
892        a.hash(&mut hasher_a);
893        let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
894        b.hash(&mut hasher_b);
895        assert_ne!(hasher_a.finish(), hasher_b.finish());
896    }
897
898    // ========== RES-002: SourceId::Path hash stability with subpath ==========
899
900    /// RES-002: SourceId::Path with subpath=None and subpath=Some("plugins/foo")
901    /// must hash to distinct values — same canonical path but different subpaths
902    /// must not collide.
903    #[test]
904    fn source_id_path_none_and_some_subpath_hash_distinctly() {
905        let canonical = PathBuf::from("/tmp/my-repo");
906        let a = SourceId::Path {
907            canonical: canonical.clone(),
908            subpath: None,
909        };
910        let b = SourceId::Path {
911            canonical: canonical.clone(),
912            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
913        };
914
915        assert_ne!(a, b);
916
917        let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
918        a.hash(&mut hasher_a);
919        let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
920        b.hash(&mut hasher_b);
921        assert_ne!(hasher_a.finish(), hasher_b.finish());
922    }
923
924    /// RES-002: Two SourceId::Path with the same canonical and same subpath must
925    /// be equal and hash equally.
926    #[test]
927    fn source_id_path_same_canonical_same_subpath_are_equal() {
928        let canonical = PathBuf::from("/tmp/my-repo");
929        let a = SourceId::Path {
930            canonical: canonical.clone(),
931            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
932        };
933        let b = SourceId::Path {
934            canonical: canonical.clone(),
935            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
936        };
937
938        assert_eq!(a, b);
939
940        let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
941        a.hash(&mut hasher_a);
942        let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
943        b.hash(&mut hasher_b);
944        assert_eq!(hasher_a.finish(), hasher_b.finish());
945    }
946
947    /// RES-002: Two SourceId::Path with same canonical but different subpaths must
948    /// not be equal and must hash differently.
949    #[test]
950    fn source_id_path_same_canonical_different_subpaths_are_distinct() {
951        let canonical = PathBuf::from("/tmp/my-repo");
952        let a = SourceId::Path {
953            canonical: canonical.clone(),
954            subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
955        };
956        let b = SourceId::Path {
957            canonical: canonical.clone(),
958            subpath: Some(SourceSubpath::new("plugins/bar").unwrap()),
959        };
960
961        assert_ne!(a, b);
962
963        let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
964        a.hash(&mut hasher_a);
965        let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
966        b.hash(&mut hasher_b);
967        assert_ne!(hasher_a.finish(), hasher_b.finish());
968    }
969
970    // ========== RES-001: lock file write + load round-trip via lock::write/load ==========
971
972    /// RES-001: A lock file written with lock::write and re-loaded with lock::load
973    /// must preserve the subpath field exactly. This exercises the full atomic
974    /// write path, not just toml::to_string.
975    #[test]
976    fn lock_write_and_load_roundtrip_preserves_subpath() {
977        use crate::lock::{LockFile, LockedSource};
978        use tempfile::TempDir;
979
980        let dir = TempDir::new().unwrap();
981        let lock = LockFile {
982            version: 1,
983            dependencies: indexmap::IndexMap::from([(
984                SourceName::from("dep"),
985                LockedSource {
986                    url: Some(SourceUrl::from("https://github.com/org/repo.git")),
987                    path: None,
988                    subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
989                    version: Some("v1.2.3".to_string()),
990                    commit: Some(CommitHash::from("deadbeef")),
991                    tree_hash: None,
992                },
993            )]),
994            items: indexmap::IndexMap::new(),
995        };
996
997        crate::lock::write(dir.path(), &lock).unwrap();
998        let loaded = crate::lock::load(dir.path()).unwrap();
999
1000        assert_eq!(
1001            loaded.dependencies["dep"]
1002                .subpath
1003                .as_ref()
1004                .map(SourceSubpath::as_str),
1005            Some("plugins/foo")
1006        );
1007        assert_eq!(
1008            loaded.dependencies["dep"].url.as_deref(),
1009            Some("https://github.com/org/repo.git")
1010        );
1011        assert_eq!(
1012            loaded.dependencies["dep"].version.as_deref(),
1013            Some("v1.2.3")
1014        );
1015    }
1016
1017    // ========== RES-001: EffectiveDependency carries subpath after merge ==========
1018
1019    /// RES-001 (config side): after merge_with_root the EffectiveDependency.subpath
1020    /// matches what was in the Config.  This confirms the subpath survives the
1021    /// config-load → merge step.
1022    #[test]
1023    fn effective_dependency_subpath_preserved_through_merge() {
1024        use crate::config::{Config, merge};
1025
1026        let toml_str = r#"
1027[dependencies.dep]
1028url = "https://github.com/org/repo.git"
1029subpath = "plugins/foo"
1030"#;
1031        let config: Config = toml::from_str(toml_str).unwrap();
1032        let effective = merge(config, crate::config::LocalConfig::default()).unwrap();
1033        assert_eq!(
1034            effective.dependencies["dep"]
1035                .subpath
1036                .as_ref()
1037                .map(SourceSubpath::as_str),
1038            Some("plugins/foo")
1039        );
1040        // SourceId must embed the same subpath
1041        assert!(matches!(
1042            &effective.dependencies["dep"].id,
1043            SourceId::Git { subpath: Some(sp), .. } if sp.as_str() == "plugins/foo"
1044        ));
1045    }
1046}