use serde::{Deserialize, Serialize};
use std::borrow::Borrow;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::ops::Deref;
use std::path::{Component, Path, PathBuf};
macro_rules! string_newtype {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(
Serialize, Deserialize, Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd,
)]
#[serde(transparent)]
pub struct $name(String);
impl $name {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl From<String> for $name {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&str> for $name {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Borrow<str> for $name {
fn borrow(&self) -> &str {
&self.0
}
}
impl Deref for $name {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<$name> for String {
fn from(value: $name) -> Self {
value.0
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl PartialEq<str> for $name {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for $name {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<String> for $name {
fn eq(&self, other: &String) -> bool {
self.0 == *other
}
}
impl PartialEq<$name> for String {
fn eq(&self, other: &$name) -> bool {
*self == other.0
}
}
};
}
string_newtype!(SourceName);
string_newtype!(ItemName);
string_newtype!(SourceUrl);
string_newtype!(CommitHash);
string_newtype!(ContentHash);
enum NormalizeError {
Empty,
Absolute,
Escaping,
}
fn normalize_relative_coordinate(raw: &str) -> Result<String, NormalizeError> {
let normalized_separators = raw.replace('\\', "/");
let mut segments = Vec::new();
for component in Path::new(&normalized_separators).components() {
match component {
Component::Normal(seg) => segments.push(seg.to_string_lossy().into_owned()),
Component::CurDir => {}
Component::ParentDir => return Err(NormalizeError::Escaping),
Component::RootDir | Component::Prefix(_) => return Err(NormalizeError::Absolute),
}
}
if segments.is_empty() {
return Err(NormalizeError::Empty);
}
Ok(segments.join("/"))
}
#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
pub struct SourceSubpath(String);
impl SourceSubpath {
pub fn new(value: impl AsRef<str>) -> Result<Self, SourceSubpathError> {
let raw = value.as_ref();
if raw.is_empty() {
return Err(SourceSubpathError::Empty);
}
let normalized_separators = raw.replace('\\', "/");
if is_windows_absolute(&normalized_separators) {
return Err(SourceSubpathError::Absolute {
input: raw.to_string(),
});
}
let normalized = normalize_relative_coordinate(raw).map_err(|err| match err {
NormalizeError::Empty => SourceSubpathError::Empty,
NormalizeError::Absolute => SourceSubpathError::Absolute {
input: raw.to_string(),
},
NormalizeError::Escaping => SourceSubpathError::Escaping {
input: raw.to_string(),
},
})?;
Ok(Self(normalized))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn as_path(&self) -> &Path {
Path::new(&self.0)
}
pub fn into_inner(self) -> String {
self.0
}
pub fn join_under(&self, base: &Path) -> Result<PathBuf, SourceSubpathError> {
let mut joined = base.to_path_buf();
for component in self.as_path().components() {
match component {
Component::Normal(seg) => joined.push(seg),
Component::CurDir => {}
Component::ParentDir => {
return Err(SourceSubpathError::Escaping {
input: self.0.clone(),
});
}
Component::RootDir | Component::Prefix(_) => {
return Err(SourceSubpathError::Absolute {
input: self.0.clone(),
});
}
}
}
if joined.strip_prefix(base).is_err() {
return Err(SourceSubpathError::Escaping {
input: self.0.clone(),
});
}
Ok(joined)
}
}
impl fmt::Display for SourceSubpath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for SourceSubpath {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl std::str::FromStr for SourceSubpath {
type Err = SourceSubpathError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl Serialize for SourceSubpath {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for SourceSubpath {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
SourceSubpath::new(value).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SourceSubpathError {
#[error("subpath cannot be empty")]
Empty,
#[error("subpath must be relative, got absolute value: {input:?}")]
Absolute { input: String },
#[error("subpath cannot escape package root: {input:?}")]
Escaping { input: String },
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum DestPathError {
#[error("destination path cannot be empty")]
Empty,
#[error("destination path must be relative, got absolute value: {input:?}")]
Absolute { input: String },
#[error("destination path cannot escape target root: {input:?}")]
Escaping { input: String },
#[error("cannot convert path to DestPath: {reason}")]
ConversionFailed { reason: String },
}
fn is_windows_absolute(path: &str) -> bool {
let bytes = path.as_bytes();
if path.starts_with('/') {
return true;
}
if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
return true;
}
false
}
fn is_windows_drive_relative(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceOrigin {
Dependency(SourceName),
LocalPackage,
}
impl fmt::Display for SourceOrigin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Dependency(name) => write!(f, "{name}"),
Self::LocalPackage => write!(f, "_self"),
}
}
}
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ItemKind {
Agent,
Skill,
Hook,
McpServer,
BootstrapDoc,
}
impl fmt::Display for ItemKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ItemKind::Agent => write!(f, "agent"),
ItemKind::Skill => write!(f, "skill"),
ItemKind::Hook => write!(f, "hook"),
ItemKind::McpServer => write!(f, "mcp-server"),
ItemKind::BootstrapDoc => write!(f, "bootstrap-doc"),
}
}
}
#[derive(Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ItemId {
pub kind: ItemKind,
pub name: ItemName,
}
impl fmt::Display for ItemId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.kind, self.name)
}
}
#[derive(Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
pub struct DestPath(String);
impl DestPath {
pub fn new(value: impl AsRef<str>) -> Result<Self, DestPathError> {
let raw = value.as_ref();
if raw.is_empty() {
return Err(DestPathError::Empty);
}
let normalized_separators = raw.replace('\\', "/");
if is_windows_absolute(&normalized_separators)
|| is_windows_drive_relative(&normalized_separators)
{
return Err(DestPathError::Absolute {
input: raw.to_string(),
});
}
let normalized = normalize_relative_coordinate(raw).map_err(|err| match err {
NormalizeError::Empty => DestPathError::Empty,
NormalizeError::Absolute => DestPathError::Absolute {
input: raw.to_string(),
},
NormalizeError::Escaping => DestPathError::Escaping {
input: raw.to_string(),
},
})?;
Ok(Self(normalized))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
pub fn resolve(&self, root: &Path) -> PathBuf {
let mut result = root.to_path_buf();
for component in self.components() {
result.push(component);
}
result
}
pub fn components(&self) -> impl Iterator<Item = &str> {
self.0.split('/')
}
pub fn item_name(&self, kind: ItemKind) -> String {
match kind {
ItemKind::BootstrapDoc => self
.0
.strip_suffix("/BOOTSTRAP.md")
.and_then(|path| path.rsplit('/').next())
.unwrap_or_else(|| self.0.rsplit('/').next().unwrap_or(""))
.to_string(),
_ => {
let last = self.0.rsplit('/').next().unwrap_or("");
match kind {
ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
ItemKind::Skill | ItemKind::Hook | ItemKind::McpServer => last.to_string(),
ItemKind::BootstrapDoc => unreachable!("handled above"),
}
}
}
}
pub fn from_host_relative(path: &Path, root: &Path) -> Result<Self, DestPathError> {
let relative = path
.strip_prefix(root)
.map_err(|_| DestPathError::ConversionFailed {
reason: format!("path {:?} is not under root {:?}", path, root),
})?;
let mut segments = Vec::new();
for component in relative.components() {
match component {
Component::Normal(seg) => segments.push(seg.to_string_lossy().into_owned()),
Component::CurDir => {}
Component::ParentDir => {
return Err(DestPathError::Escaping {
input: path.to_string_lossy().into_owned(),
});
}
Component::RootDir | Component::Prefix(_) => {
return Err(DestPathError::Absolute {
input: path.to_string_lossy().into_owned(),
});
}
}
}
if segments.is_empty() {
return Err(DestPathError::Empty);
}
Self::new(segments.join("/"))
}
}
impl From<&str> for DestPath {
fn from(value: &str) -> Self {
Self::new(value).expect("invalid destination path")
}
}
impl From<String> for DestPath {
fn from(value: String) -> Self {
Self::new(value).expect("invalid destination path")
}
}
impl AsRef<str> for DestPath {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Borrow<str> for DestPath {
fn borrow(&self) -> &str {
&self.0
}
}
impl Hash for DestPath {
fn hash<H: Hasher>(&self, state: &mut H) {
self.0.hash(state);
}
}
impl fmt::Display for DestPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl Serialize for DestPath {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.0.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for DestPath {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
DestPath::new(value).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone)]
pub struct MarsContext {
pub project_root: PathBuf,
pub managed_root: PathBuf,
pub meridian_managed: bool,
}
#[cfg(test)]
impl MarsContext {
pub fn for_test(project_root: PathBuf, managed_root: PathBuf) -> Self {
MarsContext {
project_root,
managed_root,
meridian_managed: meridian_managed_from_env(),
}
}
}
pub fn meridian_managed_from_env() -> bool {
std::env::var("MERIDIAN_MANAGED").is_ok_and(|value| value == "1")
}
pub fn managed_cmd(cmd: &str) -> std::borrow::Cow<'_, str> {
if meridian_managed_from_env() {
format!("meridian {cmd}").into()
} else {
cmd.into()
}
}
#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd, Serialize, Deserialize)]
pub enum SourceId {
Git {
url: SourceUrl,
#[serde(default, skip_serializing_if = "Option::is_none")]
subpath: Option<SourceSubpath>,
},
Path {
canonical: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
subpath: Option<SourceSubpath>,
},
}
impl SourceId {
pub fn git(url: SourceUrl) -> Self {
Self::Git { url, subpath: None }
}
pub fn git_with_subpath(url: SourceUrl, subpath: Option<SourceSubpath>) -> Self {
Self::Git { url, subpath }
}
pub fn path(base: &Path, relative_or_absolute: &Path) -> std::io::Result<Self> {
Self::path_with_subpath(base, relative_or_absolute, None)
}
pub fn path_with_subpath(
base: &Path,
relative_or_absolute: &Path,
subpath: Option<SourceSubpath>,
) -> std::io::Result<Self> {
let candidate = if relative_or_absolute.is_absolute() {
relative_or_absolute.to_path_buf()
} else {
base.join(relative_or_absolute)
};
let canonical = dunce::canonicalize(&candidate)?;
Ok(Self::Path { canonical, subpath })
}
}
impl fmt::Display for SourceId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Git { url, subpath } => {
write!(f, "git:{url}")?;
if let Some(subpath) = subpath {
write!(f, "@{subpath}")?;
}
Ok(())
}
Self::Path { canonical, subpath } => {
write!(f, "path:{}", canonical.display())?;
if let Some(subpath) = subpath {
write!(f, "@{subpath}")?;
}
Ok(())
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenameRule {
pub from: ItemName,
pub to: ItemName,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RenameMap(Vec<RenameRule>);
impl RenameMap {
pub fn new() -> Self {
Self(Vec::new())
}
pub fn insert(&mut self, from: ItemName, to: ItemName) {
if let Some(existing) = self.0.iter_mut().find(|r| r.from == from) {
existing.to = to;
return;
}
self.0.push(RenameRule { from, to });
}
pub fn push(&mut self, rule: RenameRule) {
self.insert(rule.from, rule.to);
}
pub fn get(&self, from: &str) -> Option<&ItemName> {
self.0.iter().find(|r| r.from == from).map(|r| &r.to)
}
pub fn iter(&self) -> impl Iterator<Item = &RenameRule> {
self.0.iter()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn len(&self) -> usize {
self.0.len()
}
}
impl Serialize for RenameMap {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(self.0.len()))?;
for rule in &self.0 {
map.serialize_entry(rule.from.as_str(), rule.to.as_str())?;
}
map.end()
}
}
impl<'de> Deserialize<'de> for RenameMap {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let map = indexmap::IndexMap::<String, String>::deserialize(deserializer)?;
Ok(Self(
map.into_iter()
.map(|(from, to)| RenameRule {
from: ItemName::from(from),
to: ItemName::from(to),
})
.collect(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct Wrapper<T> {
value: T,
}
#[test]
fn dest_path_roundtrip() {
let v = Wrapper {
value: DestPath::from("agents/coder.md"),
};
let s = toml::to_string(&v).unwrap();
let out: Wrapper<DestPath> = toml::from_str(&s).unwrap();
assert_eq!(v, out);
}
#[test]
fn rename_map_toml_roundtrip_compat() {
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct RenameWrapper {
rename: RenameMap,
}
let input = r#"rename = { "coder" = "cool-coder" }"#;
let parsed: RenameWrapper = toml::from_str(input).unwrap();
assert_eq!(
parsed.rename.get("coder").map(|v| v.as_str()),
Some("cool-coder")
);
let serialized = toml::to_string(&parsed).unwrap();
let reparsed: RenameWrapper = toml::from_str(&serialized).unwrap();
assert_eq!(parsed, reparsed);
}
#[test]
fn source_subpath_normalizes_windows_and_unix_separators() {
let subpath = SourceSubpath::new(r"plugins\foo/bar\baz").unwrap();
assert_eq!(subpath.as_str(), "plugins/foo/bar/baz");
}
#[test]
fn source_subpath_and_dest_path_share_normalization_rules() {
let raw = r"./plugins\foo/bar\";
let subpath = SourceSubpath::new(raw).unwrap();
let dest = DestPath::new(raw).unwrap();
assert_eq!(subpath.as_str(), "plugins/foo/bar");
assert_eq!(dest.as_str(), "plugins/foo/bar");
assert_eq!(subpath.as_str(), dest.as_str());
}
#[test]
fn source_subpath_rejects_empty() {
let err = SourceSubpath::new("").unwrap_err();
assert_eq!(err, SourceSubpathError::Empty);
}
#[test]
fn source_subpath_rejects_absolute() {
let err = SourceSubpath::new("/abs/path").unwrap_err();
assert!(matches!(err, SourceSubpathError::Absolute { .. }));
}
#[test]
fn source_subpath_rejects_root_only() {
let err = SourceSubpath::new("/").unwrap_err();
assert!(matches!(err, SourceSubpathError::Absolute { .. }));
}
#[test]
fn source_subpath_rejects_windows_absolute() {
let err = SourceSubpath::new(r"C:\abs\path").unwrap_err();
assert!(matches!(err, SourceSubpathError::Absolute { .. }));
}
#[test]
fn source_subpath_rejects_escape() {
let err = SourceSubpath::new("../escape").unwrap_err();
assert!(matches!(err, SourceSubpathError::Escaping { .. }));
}
#[test]
fn source_subpath_accepts_nested_relative_path() {
let subpath = SourceSubpath::new("a/b/c").unwrap();
assert_eq!(subpath.as_str(), "a/b/c");
}
#[test]
fn source_subpath_accepts_plugins_foo() {
let subpath = SourceSubpath::new("plugins/foo").unwrap();
assert_eq!(subpath.as_str(), "plugins/foo");
}
#[test]
fn source_subpath_serializes_with_forward_slashes() {
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct SubpathWrapper {
subpath: SourceSubpath,
}
let wrapper = SubpathWrapper {
subpath: SourceSubpath::new(r"plugins\foo").unwrap(),
};
let toml = toml::to_string(&wrapper).unwrap();
assert!(toml.contains("subpath = \"plugins/foo\""));
}
#[test]
fn source_subpath_join_under_base() {
let base = PathBuf::from("/tmp/mars");
let subpath = SourceSubpath::new("plugins/foo").unwrap();
let joined = subpath.join_under(&base).unwrap();
assert_eq!(joined, base.join("plugins").join("foo"));
}
#[test]
fn source_subpath_join_under_rejects_escape_path() {
let escaped = SourceSubpath(String::from("../escape"));
let err = escaped.join_under(Path::new("/tmp/base")).unwrap_err();
assert!(matches!(err, SourceSubpathError::Escaping { .. }));
}
#[test]
fn source_subpath_accepts_deeply_nested() {
let subpath = SourceSubpath::new("a/b/c/d/e").unwrap();
assert_eq!(subpath.as_str(), "a/b/c/d/e");
}
#[test]
fn source_subpath_rejects_windows_drive_forward_slash() {
let err = SourceSubpath::new("C:/foo").unwrap_err();
assert!(matches!(err, SourceSubpathError::Absolute { .. }));
}
#[test]
fn source_subpath_rejects_current_dir_dot() {
let err = SourceSubpath::new(".").unwrap_err();
assert_eq!(err, SourceSubpathError::Empty);
}
#[test]
fn dest_path_normalizes_windows_and_unix_separators() {
let path = DestPath::new(r"agents\foo/bar\baz.md").unwrap();
assert_eq!(path.as_str(), "agents/foo/bar/baz.md");
}
#[test]
fn dest_path_rejects_empty() {
let err = DestPath::new("").unwrap_err();
assert_eq!(err, DestPathError::Empty);
}
#[test]
fn dest_path_rejects_absolute() {
let err = DestPath::new("/abs/path").unwrap_err();
assert!(matches!(err, DestPathError::Absolute { .. }));
}
#[test]
fn dest_path_rejects_root_only() {
let err = DestPath::new("/").unwrap_err();
assert!(matches!(err, DestPathError::Absolute { .. }));
}
#[test]
fn dest_path_rejects_windows_absolute() {
let err = DestPath::new(r"C:\abs\path").unwrap_err();
assert!(matches!(err, DestPathError::Absolute { .. }));
}
#[test]
fn dest_path_rejects_windows_drive_relative() {
let err = DestPath::new("C:relative").unwrap_err();
assert!(matches!(err, DestPathError::Absolute { .. }));
}
#[test]
fn dest_path_rejects_escape() {
let err = DestPath::new("../escape").unwrap_err();
assert!(matches!(err, DestPathError::Escaping { .. }));
}
#[test]
fn dest_path_normalizes_trailing_slash() {
let path = DestPath::new("skills/planning/").unwrap();
assert_eq!(path.as_str(), "skills/planning");
}
#[test]
fn dest_path_normalizes_leading_dot_slash() {
let path = DestPath::new("./skills/planning").unwrap();
assert_eq!(path.as_str(), "skills/planning");
}
#[test]
fn dest_path_item_name_extracts_agent_leaf() {
let path = DestPath::new("agents/coder.md").unwrap();
assert_eq!(path.item_name(ItemKind::Agent), "coder");
}
#[test]
fn dest_path_item_name_extracts_skill_leaf() {
let path = DestPath::new("skills/planning").unwrap();
assert_eq!(path.item_name(ItemKind::Skill), "planning");
}
#[test]
fn dest_path_item_name_extracts_bootstrap_doc_container() {
let path = DestPath::new("bootstrap/global-auth/BOOTSTRAP.md").unwrap();
assert_eq!(path.item_name(ItemKind::BootstrapDoc), "global-auth");
}
#[test]
fn dest_path_item_name_extracts_nested_agent_leaf() {
let path = DestPath::new("agents/sub/deep.md").unwrap();
assert_eq!(path.item_name(ItemKind::Agent), "deep");
}
#[test]
fn dest_path_item_name_handles_no_slash_edge_case() {
let path = DestPath::new("solo.md").unwrap();
assert_eq!(path.item_name(ItemKind::Agent), "solo");
}
#[test]
fn source_subpath_rejects_mid_path_double_parent_escape() {
let err = SourceSubpath::new("a/../../escape").unwrap_err();
assert!(matches!(err, SourceSubpathError::Escaping { .. }));
}
#[test]
fn source_subpath_rejects_harmless_parent_in_middle() {
let err = SourceSubpath::new("a/b/../c").unwrap_err();
assert!(matches!(err, SourceSubpathError::Escaping { .. }));
}
#[test]
fn source_subpath_normalizes_trailing_slash() {
let subpath = SourceSubpath::new("plugins/foo/").unwrap();
assert_eq!(subpath.as_str(), "plugins/foo");
}
#[test]
fn source_subpath_normalizes_leading_dot_slash() {
let subpath = SourceSubpath::new("./plugins/foo").unwrap();
assert_eq!(subpath.as_str(), "plugins/foo");
}
#[test]
fn source_subpath_join_under_base_with_trailing_slash() {
let base = PathBuf::from("/tmp/mars/");
let subpath = SourceSubpath::new("plugins/foo").unwrap();
let joined = subpath.join_under(&base).unwrap();
assert_eq!(joined, PathBuf::from("/tmp/mars/plugins/foo"));
}
#[test]
fn locked_source_json_roundtrip_without_subpath() {
let json = r#"{"url":"https://github.com/org/base.git"}"#;
let parsed: crate::lock::LockedSource = serde_json::from_str(json).unwrap();
assert!(parsed.subpath.is_none());
}
#[test]
fn locked_source_json_roundtrip_with_subpath() {
let source = crate::lock::LockedSource {
url: Some(SourceUrl::from("https://github.com/org/base.git")),
path: None,
subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
version: None,
commit: None,
tree_hash: None,
};
let json = serde_json::to_string(&source).unwrap();
assert!(json.contains("\"subpath\":\"plugins/foo\""));
let reparsed: crate::lock::LockedSource = serde_json::from_str(&json).unwrap();
assert_eq!(
reparsed.subpath.as_ref().map(SourceSubpath::as_str),
Some("plugins/foo")
);
}
#[test]
fn locked_source_toml_missing_subpath_field_is_none() {
let toml_str = r#"
version = 1
[dependencies.dep]
url = "https://github.com/org/dep.git"
commit = "deadbeef"
"#;
let lock: crate::lock::LockFile = toml::from_str(toml_str).unwrap();
assert!(lock.dependencies["dep"].subpath.is_none());
}
#[test]
fn locked_source_toml_subpath_serializes_alongside_other_fields() {
let source = crate::lock::LockedSource {
url: Some(SourceUrl::from("https://github.com/org/base.git")),
path: None,
subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
version: Some("v1.0.0".to_string()),
commit: Some(CommitHash::from("abc123")),
tree_hash: None,
};
#[derive(Serialize)]
struct Wrapper {
source: crate::lock::LockedSource,
}
let serialized = toml::to_string(&Wrapper { source }).unwrap();
assert!(serialized.contains("subpath = \"plugins/foo\""));
assert!(serialized.contains("url = "));
assert!(serialized.contains("commit = "));
}
#[test]
fn lock_roundtrip_with_and_without_subpath() {
let old_lock = r#"
version = 1
[dependencies.base]
url = "https://github.com/org/base.git"
"#;
let parsed_old: crate::lock::LockFile = toml::from_str(old_lock).unwrap();
assert!(parsed_old.dependencies["base"].subpath.is_none());
let lock = crate::lock::LockFile {
version: 1,
dependencies: indexmap::IndexMap::from([(
SourceName::from("base"),
crate::lock::LockedSource {
url: Some(SourceUrl::from("https://github.com/org/base.git")),
path: None,
subpath: Some(SourceSubpath::new(r"plugins\foo").unwrap()),
version: Some("v1.2.3".to_string()),
commit: Some(CommitHash::from("abc123")),
tree_hash: None,
},
)]),
items: indexmap::IndexMap::new(),
config_entries: std::collections::BTreeMap::new(),
};
let serialized = toml::to_string_pretty(&lock).unwrap();
assert!(serialized.contains("subpath = \"plugins/foo\""));
let reparsed: crate::lock::LockFile = toml::from_str(&serialized).unwrap();
assert_eq!(
reparsed.dependencies["base"]
.subpath
.as_ref()
.map(SourceSubpath::as_str),
Some("plugins/foo")
);
}
#[test]
fn config_roundtrip_preserves_subpath() {
let config = r#"
[dependencies.base]
url = "https://github.com/org/base.git"
subpath = "plugins\\foo"
"#;
let parsed: crate::config::Config = toml::from_str(config).unwrap();
assert_eq!(
parsed.dependencies["base"]
.subpath
.as_ref()
.map(SourceSubpath::as_str),
Some("plugins/foo")
);
let serialized = toml::to_string(&parsed).unwrap();
assert!(serialized.contains("subpath = \"plugins/foo\""));
let reparsed: crate::config::Config = toml::from_str(&serialized).unwrap();
assert_eq!(
reparsed.dependencies["base"]
.subpath
.as_ref()
.map(SourceSubpath::as_str),
Some("plugins/foo")
);
}
#[test]
fn source_id_git_same_url_same_subpath_are_equal_and_hash_equal() {
let a = SourceId::git_with_subpath(
SourceUrl::from("https://example.com/repo.git"),
Some(SourceSubpath::new("plugins/foo").unwrap()),
);
let b = SourceId::git_with_subpath(
SourceUrl::from("https://example.com/repo.git"),
Some(SourceSubpath::new("plugins/foo").unwrap()),
);
assert_eq!(a, b);
let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
a.hash(&mut hasher_a);
let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
b.hash(&mut hasher_b);
assert_eq!(hasher_a.finish(), hasher_b.finish());
}
#[test]
fn source_id_git_same_url_different_subpaths_are_distinct() {
let a = SourceId::git_with_subpath(
SourceUrl::from("https://example.com/repo.git"),
Some(SourceSubpath::new("plugins/foo").unwrap()),
);
let b = SourceId::git_with_subpath(
SourceUrl::from("https://example.com/repo.git"),
Some(SourceSubpath::new("plugins/bar").unwrap()),
);
assert_ne!(a, b);
let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
a.hash(&mut hasher_a);
let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
b.hash(&mut hasher_b);
assert_ne!(hasher_a.finish(), hasher_b.finish());
}
#[test]
fn source_id_path_none_and_some_subpath_hash_distinctly() {
let canonical = PathBuf::from("/tmp/my-repo");
let a = SourceId::Path {
canonical: canonical.clone(),
subpath: None,
};
let b = SourceId::Path {
canonical: canonical.clone(),
subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
};
assert_ne!(a, b);
let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
a.hash(&mut hasher_a);
let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
b.hash(&mut hasher_b);
assert_ne!(hasher_a.finish(), hasher_b.finish());
}
#[test]
fn source_id_path_same_canonical_same_subpath_are_equal() {
let canonical = PathBuf::from("/tmp/my-repo");
let a = SourceId::Path {
canonical: canonical.clone(),
subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
};
let b = SourceId::Path {
canonical: canonical.clone(),
subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
};
assert_eq!(a, b);
let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
a.hash(&mut hasher_a);
let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
b.hash(&mut hasher_b);
assert_eq!(hasher_a.finish(), hasher_b.finish());
}
#[test]
fn source_id_path_same_canonical_different_subpaths_are_distinct() {
let canonical = PathBuf::from("/tmp/my-repo");
let a = SourceId::Path {
canonical: canonical.clone(),
subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
};
let b = SourceId::Path {
canonical: canonical.clone(),
subpath: Some(SourceSubpath::new("plugins/bar").unwrap()),
};
assert_ne!(a, b);
let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
a.hash(&mut hasher_a);
let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
b.hash(&mut hasher_b);
assert_ne!(hasher_a.finish(), hasher_b.finish());
}
#[test]
fn lock_write_and_load_roundtrip_preserves_subpath() {
use crate::lock::{LockFile, LockedSource};
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let lock = LockFile {
version: 1,
dependencies: indexmap::IndexMap::from([(
SourceName::from("dep"),
LockedSource {
url: Some(SourceUrl::from("https://github.com/org/repo.git")),
path: None,
subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
version: Some("v1.2.3".to_string()),
commit: Some(CommitHash::from("deadbeef")),
tree_hash: None,
},
)]),
items: indexmap::IndexMap::new(),
config_entries: std::collections::BTreeMap::new(),
};
crate::lock::write(dir.path(), &lock).unwrap();
let loaded = crate::lock::load(dir.path()).unwrap();
assert_eq!(
loaded.dependencies["dep"]
.subpath
.as_ref()
.map(SourceSubpath::as_str),
Some("plugins/foo")
);
assert_eq!(
loaded.dependencies["dep"].url.as_deref(),
Some("https://github.com/org/repo.git")
);
assert_eq!(
loaded.dependencies["dep"].version.as_deref(),
Some("v1.2.3")
);
}
#[test]
fn effective_dependency_subpath_preserved_through_merge() {
use crate::config::{Config, merge};
let toml_str = r#"
[dependencies.dep]
url = "https://github.com/org/repo.git"
subpath = "plugins/foo"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let effective = merge(config, crate::config::LocalConfig::default()).unwrap();
assert_eq!(
effective.dependencies["dep"]
.subpath
.as_ref()
.map(SourceSubpath::as_str),
Some("plugins/foo")
);
assert!(matches!(
&effective.dependencies["dep"].id,
SourceId::Git { subpath: Some(sp), .. } if sp.as_str() == "plugins/foo"
));
}
}