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::{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/// Where an item came from — used for lock provenance and display.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum SourceOrigin {
110    /// From a dependency (git or path source).
111    Dependency(SourceName),
112    /// From the local project's [package] declaration.
113    LocalPackage,
114}
115
116impl fmt::Display for SourceOrigin {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        match self {
119            Self::Dependency(name) => write!(f, "{name}"),
120            Self::LocalPackage => write!(f, "_self"),
121        }
122    }
123}
124
125/// Kind of installable item.
126#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
127#[serde(rename_all = "lowercase")]
128pub enum ItemKind {
129    Agent,
130    Skill,
131}
132
133impl fmt::Display for ItemKind {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        match self {
136            ItemKind::Agent => write!(f, "agent"),
137            ItemKind::Skill => write!(f, "skill"),
138        }
139    }
140}
141
142/// Stable identity for an installed item — decoupled from source URL.
143///
144/// Items are identified by `(kind, name)`, not by source URL.
145/// If a package moves to a different git host, the item identity is preserved.
146#[derive(Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
147pub struct ItemId {
148    pub kind: ItemKind,
149    pub name: ItemName,
150}
151
152impl fmt::Display for ItemId {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        write!(f, "{}/{}", self.kind, self.name)
155    }
156}
157
158/// Relative path under the install root (`.agents/` / project root).
159#[derive(Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
160pub struct DestPath(PathBuf);
161
162impl DestPath {
163    pub fn new(value: impl Into<PathBuf>) -> Self {
164        Self(value.into())
165    }
166
167    pub fn as_path(&self) -> &Path {
168        &self.0
169    }
170
171    pub fn into_inner(self) -> PathBuf {
172        self.0
173    }
174
175    /// Resolve this relative path under a root path.
176    pub fn resolve(&self, root: &Path) -> PathBuf {
177        root.join(&self.0)
178    }
179}
180
181impl From<PathBuf> for DestPath {
182    fn from(value: PathBuf) -> Self {
183        Self(value)
184    }
185}
186
187impl From<&Path> for DestPath {
188    fn from(value: &Path) -> Self {
189        Self(value.to_path_buf())
190    }
191}
192
193impl From<&str> for DestPath {
194    fn from(value: &str) -> Self {
195        Self(PathBuf::from(value))
196    }
197}
198
199impl From<String> for DestPath {
200    fn from(value: String) -> Self {
201        Self(PathBuf::from(value))
202    }
203}
204
205impl AsRef<Path> for DestPath {
206    fn as_ref(&self) -> &Path {
207        &self.0
208    }
209}
210
211impl Borrow<Path> for DestPath {
212    fn borrow(&self) -> &Path {
213        &self.0
214    }
215}
216
217impl Borrow<str> for DestPath {
218    fn borrow(&self) -> &str {
219        self.0.to_str().expect("DestPath must be valid UTF-8")
220    }
221}
222
223impl Hash for DestPath {
224    fn hash<H: Hasher>(&self, state: &mut H) {
225        self.0.to_string_lossy().hash(state);
226    }
227}
228
229impl Deref for DestPath {
230    type Target = Path;
231
232    fn deref(&self) -> &Self::Target {
233        &self.0
234    }
235}
236
237impl fmt::Display for DestPath {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        write!(f, "{}", self.0.display())
240    }
241}
242
243impl Serialize for DestPath {
244    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
245        self.0.to_string_lossy().serialize(serializer)
246    }
247}
248
249impl<'de> Deserialize<'de> for DestPath {
250    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
251        String::deserialize(deserializer).map(|s| Self(PathBuf::from(s)))
252    }
253}
254
255/// Resolved context for a mars command — project root + managed output root.
256///
257/// Named fields prevent argument-order bugs that plague `(project_root, managed_root)` pairs.
258#[derive(Debug, Clone)]
259pub struct MarsContext {
260    /// Project root containing mars.toml and mars.lock.
261    pub project_root: PathBuf,
262    /// Managed output directory (e.g. /project/.agents).
263    pub managed_root: PathBuf,
264}
265
266#[cfg(test)]
267impl MarsContext {
268    /// Create a MarsContext for tests without any validation.
269    pub fn for_test(project_root: PathBuf, managed_root: PathBuf) -> Self {
270        MarsContext {
271            project_root,
272            managed_root,
273        }
274    }
275}
276
277/// Stable source identity used for resolver deduplication.
278#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
279pub enum SourceId {
280    Git { url: SourceUrl },
281    Path { canonical: PathBuf },
282}
283
284impl SourceId {
285    pub fn git(url: SourceUrl) -> Self {
286        Self::Git { url }
287    }
288
289    pub fn path(base: &Path, relative_or_absolute: &Path) -> std::io::Result<Self> {
290        let candidate = if relative_or_absolute.is_absolute() {
291            relative_or_absolute.to_path_buf()
292        } else {
293            base.join(relative_or_absolute)
294        };
295        let canonical = candidate.canonicalize()?;
296        Ok(Self::Path { canonical })
297    }
298}
299
300impl fmt::Display for SourceId {
301    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
302        match self {
303            Self::Git { url } => write!(f, "git:{url}"),
304            Self::Path { canonical } => write!(f, "path:{}", canonical.display()),
305        }
306    }
307}
308
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct RenameRule {
311    pub from: ItemName,
312    pub to: ItemName,
313}
314
315/// Ordered rename rules, serialized as TOML inline table/map for compatibility.
316#[derive(Debug, Clone, Default, PartialEq, Eq)]
317pub struct RenameMap(Vec<RenameRule>);
318
319impl RenameMap {
320    pub fn new() -> Self {
321        Self(Vec::new())
322    }
323
324    pub fn insert(&mut self, from: ItemName, to: ItemName) {
325        if let Some(existing) = self.0.iter_mut().find(|r| r.from == from) {
326            existing.to = to;
327            return;
328        }
329        self.0.push(RenameRule { from, to });
330    }
331
332    pub fn push(&mut self, rule: RenameRule) {
333        self.insert(rule.from, rule.to);
334    }
335
336    pub fn get(&self, from: &str) -> Option<&ItemName> {
337        self.0.iter().find(|r| r.from == from).map(|r| &r.to)
338    }
339
340    pub fn iter(&self) -> impl Iterator<Item = &RenameRule> {
341        self.0.iter()
342    }
343
344    pub fn is_empty(&self) -> bool {
345        self.0.is_empty()
346    }
347
348    pub fn len(&self) -> usize {
349        self.0.len()
350    }
351}
352
353impl Serialize for RenameMap {
354    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
355        use serde::ser::SerializeMap;
356        let mut map = serializer.serialize_map(Some(self.0.len()))?;
357        for rule in &self.0 {
358            map.serialize_entry(rule.from.as_str(), rule.to.as_str())?;
359        }
360        map.end()
361    }
362}
363
364impl<'de> Deserialize<'de> for RenameMap {
365    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
366        let map = indexmap::IndexMap::<String, String>::deserialize(deserializer)?;
367        Ok(Self(
368            map.into_iter()
369                .map(|(from, to)| RenameRule {
370                    from: ItemName::from(from),
371                    to: ItemName::from(to),
372                })
373                .collect(),
374        ))
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use serde::{Deserialize, Serialize};
382
383    #[derive(Debug, Serialize, Deserialize, PartialEq)]
384    struct Wrapper<T> {
385        value: T,
386    }
387
388    #[test]
389    fn dest_path_roundtrip() {
390        let v = Wrapper {
391            value: DestPath::from("agents/coder.md"),
392        };
393        let s = toml::to_string(&v).unwrap();
394        let out: Wrapper<DestPath> = toml::from_str(&s).unwrap();
395        assert_eq!(v, out);
396    }
397
398    #[test]
399    fn rename_map_toml_roundtrip_compat() {
400        #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
401        struct RenameWrapper {
402            rename: RenameMap,
403        }
404
405        let input = r#"rename = { "coder" = "cool-coder" }"#;
406        let parsed: RenameWrapper = toml::from_str(input).unwrap();
407        assert_eq!(
408            parsed.rename.get("coder").map(|v| v.as_str()),
409            Some("cool-coder")
410        );
411
412        let serialized = toml::to_string(&parsed).unwrap();
413        let reparsed: RenameWrapper = toml::from_str(&serialized).unwrap();
414        assert_eq!(parsed, reparsed);
415    }
416}