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