use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{MindError, Result};
use crate::paths::Paths;
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "kebab-case")]
pub enum Pin {
#[default]
DefaultBranch,
FollowBranch(String),
Tag(String),
Ref(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecordedHook {
pub command: String,
#[serde(default)]
pub ran_at: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RecordedEvent {
Install,
Update,
}
impl RecordedEvent {
pub fn as_str(self) -> &'static str {
match self {
RecordedEvent::Install => "install",
RecordedEvent::Update => "update",
}
}
pub fn of(event: crate::mindfile::HookEvent) -> Option<RecordedEvent> {
match event {
crate::mindfile::HookEvent::Install => Some(RecordedEvent::Install),
crate::mindfile::HookEvent::Update => Some(RecordedEvent::Update),
crate::mindfile::HookEvent::Uninstall => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HookOrigin {
Override,
Curated,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecordedSourceHook {
pub command: String,
#[serde(default)]
pub ran_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event: Option<RecordedEvent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub optional: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<HookOrigin>,
#[serde(default, skip_serializing_if = "is_false")]
pub baseline: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
impl RecordedSourceHook {
pub fn install(command: impl Into<String>, ran_at: Option<String>) -> RecordedSourceHook {
RecordedSourceHook {
command: command.into(),
ran_at,
event: Some(RecordedEvent::Install),
name: None,
optional: false,
origin: None,
baseline: false,
}
}
pub fn event(&self) -> RecordedEvent {
self.event.unwrap_or(RecordedEvent::Install)
}
pub fn is(&self, command: &str, event: RecordedEvent) -> bool {
self.command == command && self.event() == event
}
pub fn pending(&self, current: Option<&str>) -> bool {
self.ran_at.is_none() || self.ran_at.as_deref() != current
}
pub fn label(&self) -> &str {
match self.name.as_deref() {
Some(n) if !n.trim().is_empty() => n,
_ => &self.command,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ManifestOrigin {
ClaudePlugin,
ClaudeMarketplace,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Source {
pub name: String,
pub url: String,
pub host: String,
pub owner: String,
pub repo: String,
#[serde(default)]
pub commit: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub alias: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub as_alias: Option<String>,
#[serde(default)]
pub pin: Pin,
#[serde(default)]
pub roots: Option<Vec<String>>,
#[serde(default)]
pub flat_skills: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub add_roots: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub item_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub curated_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub item_kind: Option<crate::error::ItemKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<ManifestOrigin>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_version: Option<String>,
#[serde(default)]
pub install_hooks: Vec<RecordedSourceHook>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub install_hook: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub install_hook_commit: Option<String>,
}
impl Source {
pub fn is_local(&self) -> bool {
self.host == "local"
}
pub fn base_identity(&self) -> String {
format!("{}/{}/{}", self.host, self.owner, self.repo)
}
pub fn compute_name(&self) -> String {
let mut n = self.base_identity();
if let Some(p) = &self.item_path {
n.push('#');
n.push_str(p);
}
if let Some(a) = &self.as_alias
&& !a.is_empty()
{
n.push('@');
n.push_str(a);
}
n
}
pub fn refresh_name(&mut self) {
self.name = self.compute_name();
}
pub fn apply_alias(&mut self, alias: Option<String>) {
self.as_alias = alias.clone();
self.alias = alias;
self.refresh_name();
}
pub fn is_linked(&self) -> bool {
self.is_local() && self.pin == Pin::DefaultBranch
}
pub fn clone_dir(&self, paths: &Paths) -> PathBuf {
if self.is_linked() {
return PathBuf::from(&self.url);
}
let leaf = clone_dir_leaf(
&self.repo,
self.item_path.as_deref(),
self.as_alias.as_deref(),
);
paths
.sources_dir()
.join(&self.host)
.join(&self.owner)
.join(leaf)
}
pub fn compare_url(&self, from: &str, to: &str) -> Option<String> {
if !self.url.starts_with("https://") {
return None;
}
let host_lower = self.host.to_ascii_lowercase();
if host_lower.contains("gitlab") || host_lower.contains("bitbucket") {
return None;
}
Some(format!(
"https://{}/{}/{}/compare/{from}...{to}",
self.host, self.owner, self.repo
))
}
pub fn browse_url(&self, commit: &str) -> Option<String> {
if !self.url.starts_with("https://") {
return None;
}
let host_lower = self.host.to_ascii_lowercase();
if host_lower.contains("gitlab") || host_lower.contains("bitbucket") {
return None;
}
Some(format!(
"https://{}/{}/{}/tree/{commit}",
self.host, self.owner, self.repo
))
}
pub fn ssh_url(&self) -> String {
format!("git@{}:{}/{}", self.host, self.owner, self.repo)
}
pub fn prefer_ssh(&mut self, prefer_ssh: bool) {
if prefer_ssh
&& self.host != "local"
&& (self.url.starts_with("https://") || self.url.starts_with("http://"))
{
self.url = self.ssh_url();
}
}
pub fn migrate_legacy_hook(&mut self) {
if self.install_hooks.is_empty()
&& let Some(cmd) = self.install_hook.take()
&& !cmd.trim().is_empty()
{
let ran_at = self.install_hook_commit.take();
self.install_hooks
.push(RecordedSourceHook::install(cmd, ran_at));
}
self.install_hook = None;
self.install_hook_commit = None;
}
pub fn pending_install_hooks(
&self,
current: Option<&str>,
declared: &[String],
keep_curated: bool,
) -> Vec<&RecordedSourceHook> {
self.install_hooks
.iter()
.filter(|h| h.event() == RecordedEvent::Install)
.filter(|h| match h.origin {
Some(HookOrigin::Override) => true,
Some(HookOrigin::Curated) => keep_curated,
None => declared.iter().any(|d| d == &h.command),
})
.filter(|h| h.pending(current))
.collect()
}
pub fn override_command(&self) -> Option<&str> {
self.install_hooks
.iter()
.find(|h| h.origin == Some(HookOrigin::Override))
.map(|h| h.command.as_str())
}
fn revalidate(&self) -> std::result::Result<(), (&'static str, String)> {
validate_identity_part(&self.name, "host", &self.host, true, true)
.map_err(|e| ("host", e.to_string()))?;
validate_identity_part(&self.name, "owner", &self.owner, false, true)
.map_err(|e| ("owner", e.to_string()))?;
validate_identity_part(&self.name, "repo", &self.repo, true, true)
.map_err(|e| ("repo", e.to_string()))?;
if let Some(alias) = &self.as_alias {
crate::namespace::validate_prefix(alias).map_err(|e| ("as_alias", e.to_string()))?;
}
let pin_value = match &self.pin {
Pin::DefaultBranch => None,
Pin::FollowBranch(v) | Pin::Tag(v) | Pin::Ref(v) => Some(v.as_str()),
};
if let Some(v) = pin_value {
crate::git::validate_ref_value(v).map_err(|e| ("pin", e.to_string()))?;
}
Ok(())
}
}
fn clone_dir_leaf(repo: &str, item_path: Option<&str>, alias: Option<&str>) -> String {
let alias_suffix = match alias {
Some(a) if !a.is_empty() => format!("@{a}"),
_ => String::new(),
};
let Some(item_path) = item_path else {
return format!("{repo}{alias_suffix}");
};
let encoded = encode_item_path_segment(item_path);
let leaf = format!("{repo}#{encoded}{alias_suffix}");
if leaf.len() > 120 {
let hashed = crate::hash::hash_str(item_path);
format!("{repo}#{hashed}{alias_suffix}")
} else {
leaf
}
}
fn encode_item_path_segment(item_path: &str) -> String {
item_path.replace('%', "%25").replace('/', "%2F")
}
fn revalidate_sources(sources: Vec<Source>, mut warn: impl FnMut(&str)) -> Vec<Source> {
sources
.into_iter()
.filter_map(|src| match src.revalidate() {
Ok(()) => Some(src),
Err((part, e)) => {
warn(&format!(
"warning: sources.json: dropping source '{}': its {part} part failed \
validation ({e})",
src.name
));
None
}
})
.collect()
}
pub fn parse_spec(spec: &str) -> Result<Source> {
parse_spec_inner(spec, true)
}
pub(crate) fn parse_spec_quiet(spec: &str) -> Result<Source> {
parse_spec_inner(spec, false)
}
fn parse_spec_inner(spec: &str, note: bool) -> Result<Source> {
let spec = spec.trim();
let invalid = || MindError::InvalidRepoSpec {
spec: spec.to_string(),
};
let local = spec.strip_prefix("file://");
if local.is_some() || spec.starts_with('/') || spec.starts_with("./") || spec.starts_with("../")
{
let path = local.unwrap_or(spec);
if local.is_some()
&& let Some((repo_part, marker, rest)) = split_link_marker(path)
{
let (pin, item_path) = parse_link_tail(spec, marker, rest)?;
let abs_repo_part = absolutize(repo_part);
let mut comps = abs_repo_part.trim_end_matches('/').rsplit('/');
let repo_raw = comps.next().filter(|s| !s.is_empty()).ok_or_else(invalid)?;
let repo = repo_raw.strip_suffix(".git").unwrap_or(repo_raw);
let owner = comps
.next()
.filter(|s| !s.is_empty() && *s != "." && *s != "..")
.unwrap_or("local");
let mut source = make_source(spec, "local", owner, repo, abs_repo_part.clone())?;
source.name = format!("{}#{item_path}", source.name);
source.item_path = Some(item_path);
source.pin = pin;
return Ok(source);
}
let abs_path = absolutize(path);
let mut comps = abs_path.trim_end_matches('/').rsplit('/');
let repo_raw = comps.next().filter(|s| !s.is_empty()).ok_or_else(invalid)?;
let repo = repo_raw.strip_suffix(".git").unwrap_or(repo_raw);
let owner = comps
.next()
.filter(|s| !s.is_empty() && *s != "." && *s != "..")
.unwrap_or("local");
return make_source(spec, "local", owner, repo, abs_path.clone());
}
if let Some(rest) = spec.strip_prefix("git@") {
let (host, path) = rest.split_once(':').ok_or_else(invalid)?;
let (owner, repo) = split_owner_repo(path).ok_or_else(invalid)?;
return make_source(spec, host, &owner, &repo, spec.to_string());
}
if let Some((scheme, rest)) = spec.split_once("://") {
let (authority, path) = rest.split_once('/').ok_or_else(invalid)?;
let (userinfo, host) = match authority.rsplit_once('@') {
Some((info, h)) => (Some(info), h),
None => (None, authority),
};
if let Some(info) = userinfo {
if scheme != "ssh" {
return Err(MindError::UnsafeRepoSpec {
spec: spec.to_string(),
part: "host",
value: authority.to_string(),
reason: "embeds a credential (userinfo before '@'); a credential must not \
be persisted into sources.json or echoed back by dump",
});
}
if info.is_empty() || info.contains(|c: char| c.is_control()) {
return Err(MindError::UnsafeRepoSpec {
spec: spec.to_string(),
part: "host",
value: authority.to_string(),
reason: "the ssh userinfo is empty or contains a control character",
});
}
}
if let Some(source) = parse_item_link(spec, scheme, host, authority, path)? {
return Ok(source);
}
let (owner, repo) = split_owner_repo(path).ok_or_else(invalid)?;
let url = format!("{scheme}://{authority}/{owner}/{repo}");
return make_source(spec, host, &owner, &repo, url);
}
let bare = spec.strip_prefix("github:").unwrap_or(spec);
let (owner, repo) = split_owner_repo(bare).ok_or_else(invalid)?;
if note
&& let Ok(cwd) = std::env::current_dir()
&& local_dir_shadow(&cwd, bare)
{
eprintln!(
"note: '{bare}' is also a directory here; to meld/review it as a local path instead \
of a remote repo, use './{bare}'"
);
}
let url = format!("https://github.com/{owner}/{repo}");
make_source(spec, "github.com", &owner, &repo, url)
}
fn absolutize(path: &str) -> String {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
absolutize_against(&cwd, path)
}
pub(crate) fn is_relative_local_spec(spec: &str) -> bool {
spec.starts_with("./") || spec.starts_with("../")
}
pub(crate) fn absolutize_against(base: &Path, path: &str) -> String {
let p = Path::new(path);
let joined = if p.is_absolute() {
p.to_path_buf()
} else {
base.join(p)
};
lexically_normalize(&joined)
}
fn lexically_normalize(path: &Path) -> String {
use std::path::Component;
let mut out: Vec<Component> = Vec::new();
for comp in path.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => match out.last() {
Some(Component::Normal(_)) => {
out.pop();
}
_ => out.push(comp),
},
other => out.push(other),
}
}
let mut result = PathBuf::new();
for c in &out {
result.push(c);
}
if result.as_os_str().is_empty() {
result.push(".");
}
result.to_string_lossy().into_owned()
}
pub(crate) fn resolve_local_dir(base: &Path, spec: &str) -> Option<PathBuf> {
let p = Path::new(spec);
let joined = if p.is_absolute() {
p.to_path_buf()
} else {
base.join(p)
};
joined.is_dir().then_some(joined)
}
pub(crate) fn local_dir_shadow(base: &Path, spec: &str) -> bool {
resolve_local_dir(base, spec).is_some()
}
fn migrate_relative_local_url(base: &Path, src: &mut Source) -> bool {
if !src.is_local() || Path::new(&src.url).is_absolute() {
return false;
}
let abs = absolutize_against(base, &src.url);
if Path::new(&abs).is_dir() {
src.url = abs;
true
} else {
false
}
}
fn split_link_marker(s: &str) -> Option<(&str, &'static str, &str)> {
const MARKERS: [(&str, &str); 4] = [
("/-/tree/", "tree"),
("/-/blob/", "blob"),
("/tree/", "tree"),
("/blob/", "blob"),
];
MARKERS
.iter()
.filter_map(|(pat, kind)| s.find(pat).map(|idx| (idx, pat.len(), *kind)))
.min_by_key(|&(idx, _, _)| idx)
.map(|(idx, len, kind)| (&s[..idx], kind, &s[idx + len..]))
}
fn parse_link_tail(spec: &str, marker: &str, rest: &str) -> Result<(Pin, String)> {
let bad = |reason: &str| MindError::BadItemLink {
url: spec.to_string(),
reason: reason.to_string(),
};
let mut segs = rest.trim_matches('/').split('/');
let r = segs
.next()
.filter(|s| !s.is_empty())
.ok_or_else(|| bad("missing a ref (branch, tag, or commit) after tree/blob"))?;
let mut parts: Vec<&str> = segs.filter(|p| *p != ".").collect();
let last_is_md = parts.last().is_some_and(|p| p.ends_with(".md"));
if parts.last() == Some(&"SKILL.md") {
parts.pop();
} else if marker == "blob" && !last_is_md {
return Err(bad(
"a blob link must name a `.md` file (an item file, or a skill's SKILL.md)",
));
}
if parts.is_empty() || parts.iter().any(|p| p.is_empty()) {
return Err(bad("missing the item path after the ref"));
}
let item_path = parts.join("/");
if !crate::plugin_manifest::is_safe_manifest_path(&item_path) {
return Err(bad("the item path is not a safe repo-relative path"));
}
if item_path.contains(['@', '#']) {
return Err(bad(
"the item path may not contain '@' or '#'; they are identity markers",
));
}
if crate::git::validate_ref_value(r).is_err() {
return Err(bad("the ref is not a valid git ref"));
}
let pin = if r.len() == 40 && r.bytes().all(|b| b.is_ascii_hexdigit()) {
Pin::Ref(r.to_string())
} else {
Pin::FollowBranch(r.to_string())
};
Ok((pin, item_path))
}
fn parse_item_link(
spec: &str,
scheme: &str,
host: &str,
authority: &str,
path: &str,
) -> Result<Option<Source>> {
let path = path.split(['?', '#']).next().unwrap_or(path);
let Some((repo_part, marker, rest)) = split_link_marker(path) else {
return Ok(None);
};
let invalid = || MindError::InvalidRepoSpec {
spec: spec.to_string(),
};
let (owner, repo) = split_owner_repo(repo_part).ok_or_else(invalid)?;
let (pin, item_path) = parse_link_tail(spec, marker, rest)?;
let url = format!("{scheme}://{authority}/{owner}/{repo}");
let mut source = make_source(spec, host, &owner, &repo, url)?;
source.name = format!("{}#{item_path}", source.name);
source.item_path = Some(item_path);
source.pin = pin;
Ok(Some(source))
}
fn split_owner_repo(path: &str) -> Option<(String, String)> {
let path = path.trim_matches('/');
let (owner, repo) = path.split_once('/')?;
let repo = repo.strip_suffix(".git").unwrap_or(repo);
if owner.is_empty() || repo.is_empty() || repo.contains('/') {
return None;
}
Some((owner.to_string(), repo.to_string()))
}
fn validate_identity_part(
spec: &str,
part: &'static str,
value: &str,
reject_at: bool,
reject_hash: bool,
) -> Result<()> {
let bad = |reason: &'static str| MindError::UnsafeRepoSpec {
spec: spec.to_string(),
part,
value: value.to_string(),
reason,
};
if value.is_empty() {
return Err(bad("is empty"));
}
if value == "." || value == ".." {
return Err(bad("is a relative path component"));
}
if value.contains('/') || value.contains('\\') {
return Err(bad("contains a path separator"));
}
if value.contains(|c: char| c.is_control()) {
return Err(bad("contains a control character"));
}
if reject_at && reject_hash && value.contains(['@', '#']) {
return Err(bad("contains '@' or '#'"));
}
if reject_at && value.contains('@') {
return Err(bad("contains '@'"));
}
if reject_hash && value.contains('#') {
return Err(bad("contains '#'"));
}
Ok(())
}
fn make_source(spec: &str, host: &str, owner: &str, repo: &str, url: String) -> Result<Source> {
validate_identity_part(spec, "host", host, true, true)?;
validate_identity_part(spec, "owner", owner, false, true)?;
validate_identity_part(spec, "repo", repo, true, true)?;
Ok(Source {
name: format!("{host}/{owner}/{repo}"),
url,
host: host.to_string(),
owner: owner.to_string(),
repo: repo.to_string(),
commit: None,
description: None,
alias: None,
as_alias: None,
pin: Pin::default(),
roots: None,
flat_skills: false,
add_roots: None,
item_path: None,
item_kind: None,
curated_by: None,
origin: None,
plugin_version: None,
install_hooks: Vec::new(),
install_hook: None,
install_hook_commit: None,
})
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Registry {
#[serde(default)]
pub sources: Vec<Source>,
}
#[derive(Deserialize)]
struct RegistryWithVersion {
#[serde(default = "default_version")]
version: u32,
#[serde(default)]
sources: Vec<Source>,
}
const REGISTRY_VERSION: u32 = 1;
fn default_version() -> u32 {
1
}
impl Registry {
pub fn load(paths: &Paths) -> Result<Self> {
let file = paths.sources_file();
match std::fs::read(&file) {
Ok(bytes) => {
let raw: RegistryWithVersion = serde_json::from_slice(&bytes)
.map_err(|e| MindError::json("sources.json", e))?;
if raw.version > REGISTRY_VERSION {
return Err(MindError::StateTooNew {
what: "sources.json",
found: raw.version,
supported: REGISTRY_VERSION,
});
}
let mut reg = Registry {
sources: raw.sources,
};
for src in &mut reg.sources {
src.migrate_legacy_hook();
}
reg.sources = revalidate_sources(reg.sources, |msg| eprintln!("{msg}"));
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
for src in &mut reg.sources {
migrate_relative_local_url(&cwd, src);
}
Ok(reg)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Registry::default()),
Err(e) => Err(MindError::io(&file, e)),
}
}
pub fn save(&self, paths: &Paths) -> Result<()> {
paths.ensure_layout()?;
let file = paths.sources_file();
let versioned = serde_json::json!({
"version": REGISTRY_VERSION,
"sources": self.sources,
});
let json = serde_json::to_vec_pretty(&versioned)
.map_err(|e| MindError::json("sources.json", e))?;
Paths::atomic_write(&file, &json)
}
pub fn find(&self, name: &str) -> Option<&Source> {
self.sources.iter().find(|s| s.name == name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_owner_repo_shorthand() {
let s = parse_spec("james/agents").unwrap();
assert_eq!(s.host, "github.com");
assert_eq!(s.owner, "james");
assert_eq!(s.repo, "agents");
assert_eq!(s.name, "github.com/james/agents");
assert_eq!(s.url, "https://github.com/james/agents");
}
#[test]
fn identity_is_host_owner_repo_so_basenames_can_repeat() {
assert_eq!(
parse_spec("james/agents").unwrap().name,
"github.com/james/agents"
);
assert_eq!(
parse_spec("bob/agents").unwrap().name,
"github.com/bob/agents"
);
assert_ne!(
parse_spec("james/agents").unwrap().name,
parse_spec("bob/agents").unwrap().name
);
assert_ne!(
parse_spec("https://github.com/a/b").unwrap().name,
parse_spec("https://gitlab.com/a/b").unwrap().name
);
}
#[test]
fn parses_github_prefix() {
let s = parse_spec("github:foo/bar").unwrap();
assert_eq!(s.url, "https://github.com/foo/bar");
}
#[test]
fn parses_github_tree_link() {
let s = parse_spec("https://github.com/o/r/tree/main/skills/foo").unwrap();
assert_eq!(s.name, "github.com/o/r#skills/foo");
assert_eq!(s.url, "https://github.com/o/r");
assert_eq!(s.item_path.as_deref(), Some("skills/foo"));
assert_eq!(s.pin, Pin::FollowBranch("main".into()));
assert_eq!(s.base_identity(), "github.com/o/r");
}
#[test]
fn parses_blob_link_and_strips_skill_md() {
let s = parse_spec("https://github.com/o/r/blob/main/skills/foo/SKILL.md").unwrap();
assert_eq!(s.item_path.as_deref(), Some("skills/foo"));
let t = parse_spec("https://github.com/o/r/tree/main/skills/foo/SKILL.md").unwrap();
assert_eq!(t.item_path.as_deref(), Some("skills/foo"));
assert!(parse_spec("https://github.com/o/r/blob/main/skills/foo").is_err());
}
#[test]
fn parses_a_remote_blob_link_to_a_single_file() {
let s = parse_spec("https://github.com/o/r/blob/main/agents/dev.md").unwrap();
assert_eq!(s.item_path.as_deref(), Some("agents/dev.md"));
assert_eq!(s.name, "github.com/o/r#agents/dev.md");
let t = parse_spec("https://github.com/o/r/tree/main/agents/dev.md").unwrap();
assert_eq!(t.item_path.as_deref(), Some("agents/dev.md"));
assert_eq!(t.name, "github.com/o/r#agents/dev.md");
}
#[test]
fn parses_gitlab_dash_link_forms() {
let s = parse_spec("https://gitlab.com/o/r/-/tree/main/skills/foo").unwrap();
assert_eq!(s.name, "gitlab.com/o/r#skills/foo");
let b = parse_spec("https://gitlab.com/o/r/-/blob/v2/skills/foo/SKILL.md").unwrap();
assert_eq!(b.pin, Pin::FollowBranch("v2".into()));
}
#[test]
fn link_query_and_fragment_are_stripped() {
let s =
parse_spec("https://github.com/o/r/blob/main/skills/foo/SKILL.md?plain=1#L10").unwrap();
assert_eq!(s.item_path.as_deref(), Some("skills/foo"));
}
#[test]
fn link_forty_hex_ref_pins_the_commit() {
let sha = "0123456789abcdef0123456789abcdef01234567";
let s = parse_spec(&format!("https://github.com/o/r/tree/{sha}/skills/foo")).unwrap();
assert_eq!(s.pin, Pin::Ref(sha.into()));
}
#[test]
fn link_marker_without_a_valid_tail_is_bad_item_link() {
assert!(matches!(
parse_spec("https://github.com/o/r/tree/main"),
Err(MindError::BadItemLink { .. })
));
assert!(matches!(
parse_spec("https://github.com/o/r/tree/"),
Err(MindError::BadItemLink { .. })
));
let err = parse_spec("https://github.com/o/r/tree/main").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("tree/<ref>"), "{msg}");
assert!(msg.contains("blob/<ref>"), "{msg}");
assert!(
msg.contains("https://github.com/o/r/tree/main"),
"message must include the offending URL: {msg}"
);
}
#[test]
fn a_plain_non_link_bad_spec_stays_invalid_repo_spec() {
assert!(matches!(
parse_spec("https://github.com/"),
Err(MindError::InvalidRepoSpec { .. })
));
}
#[test]
fn link_unsafe_path_or_ref_is_rejected_at_parse() {
assert!(matches!(
parse_spec("https://github.com/o/r/tree/main/../../etc"),
Err(MindError::BadItemLink { .. })
));
assert!(matches!(
parse_spec("https://github.com/o/r/tree/a..b/skills/foo"),
Err(MindError::BadItemLink { .. })
));
assert!(matches!(
parse_spec("https://github.com/o/r/tree/-evil/skills/foo"),
Err(MindError::BadItemLink { .. })
));
}
#[test]
fn gitlab_dash_link_bad_tail_is_bad_item_link() {
assert!(matches!(
parse_spec("https://gitlab.com/o/r/-/tree/"),
Err(MindError::BadItemLink { .. })
));
assert!(matches!(
parse_spec("https://gitlab.com/o/r/-/tree/main"),
Err(MindError::BadItemLink { .. })
));
assert!(matches!(
parse_spec("https://gitlab.com/o/r/-/blob/main/skills/foo"),
Err(MindError::BadItemLink { .. })
));
assert!(matches!(
parse_spec("https://gitlab.com/o/r/-/tree/main/../etc"),
Err(MindError::BadItemLink { .. })
));
let err = parse_spec("https://gitlab.com/o/r/-/tree/main").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("https://gitlab.com/o/r/-/tree/main"),
"message must include the offending URL: {msg}"
);
assert!(
msg.contains("tree/<ref>") && msg.contains("blob/<ref>"),
"{msg}"
);
}
#[test]
fn file_link_bad_tail_is_bad_item_link() {
assert!(matches!(
parse_spec("file:///home/me/dev/agents/tree/main"),
Err(MindError::BadItemLink { .. })
));
assert!(matches!(
parse_spec("file:///home/me/dev/agents/tree/"),
Err(MindError::BadItemLink { .. })
));
assert!(matches!(
parse_spec("file:///home/me/dev/agents/blob/main/skills/foo"),
Err(MindError::BadItemLink { .. })
));
assert!(matches!(
parse_spec("file:///home/me/dev/agents/tree/main/../../etc"),
Err(MindError::BadItemLink { .. })
));
let err = parse_spec("file:///home/me/dev/agents/tree/main").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("file:///home/me/dev/agents/tree/main"),
"message must include the offending URL: {msg}"
);
}
#[test]
fn file_link_bad_tail_with_no_repo_ahead_of_marker_is_still_bad_item_link() {
assert!(matches!(
parse_spec("file:///tree/main"),
Err(MindError::BadItemLink { .. })
));
}
#[test]
fn github_malformed_owner_repo_wins_over_bad_tail() {
assert!(matches!(
parse_spec("https://github.com/only-one-segment/tree/main"),
Err(MindError::InvalidRepoSpec { .. })
));
}
#[test]
fn uppercase_tree_segment_is_not_a_link_marker() {
let err = parse_spec("https://github.com/o/r/TREE/main/skills/foo").unwrap_err();
assert!(
matches!(err, MindError::InvalidRepoSpec { .. }),
"uppercase TREE must not be treated as a link marker: {err:?}"
);
}
#[test]
fn doubled_slash_after_marker_absorbs_into_the_ref_not_an_error() {
let s = parse_spec("https://github.com/o/r/tree//skills/foo").unwrap();
assert_eq!(s.pin, Pin::FollowBranch("skills".into()));
assert_eq!(s.item_path.as_deref(), Some("foo"));
}
#[test]
fn query_and_fragment_noise_is_stripped_before_tail_validation_too() {
assert!(matches!(
parse_spec("https://github.com/o/r/tree/main?plain=1#L10"),
Err(MindError::BadItemLink { .. })
));
let err = parse_spec("https://github.com/o/r/blob/main/skills/foo?plain=1").unwrap_err();
assert!(matches!(err, MindError::BadItemLink { .. }));
assert!(
err.to_string().contains("?plain=1"),
"the reported url is the original spec verbatim: {err}"
);
}
#[test]
fn file_link_is_a_pinned_local_instance() {
let s = parse_spec("file:///home/me/dev/agents/tree/main/skills/foo").unwrap();
assert_eq!(s.host, "local");
assert_eq!(s.name, "local/dev/agents#skills/foo");
assert_eq!(s.url, "/home/me/dev/agents");
assert_eq!(s.item_path.as_deref(), Some("skills/foo"));
assert_eq!(s.pin, Pin::FollowBranch("main".into()));
assert!(!s.is_linked());
let plain = parse_spec("/home/me/dev/agents/tree/main/skills/foo").unwrap();
assert!(plain.item_path.is_none());
assert_eq!(plain.repo, "foo");
}
#[test]
fn plain_repo_url_is_not_an_item_link() {
let s = parse_spec("https://github.com/o/r").unwrap();
assert!(s.item_path.is_none());
assert_eq!(s.pin, Pin::DefaultBranch);
}
#[test]
fn parses_https_url_and_strips_dot_git() {
let s = parse_spec("https://github.com/foo/bar.git").unwrap();
assert_eq!(s.host, "github.com");
assert_eq!(s.repo, "bar");
assert_eq!(s.url, "https://github.com/foo/bar");
}
#[test]
fn parses_ssh_form() {
let s = parse_spec("git@github.com:foo/bar.git").unwrap();
assert_eq!(s.host, "github.com");
assert_eq!(s.owner, "foo");
assert_eq!(s.repo, "bar");
}
#[test]
fn ssh_url_with_userinfo_keeps_it_in_url_but_not_identity() {
let s = parse_spec("ssh://git@github.com/acme/agents").unwrap();
assert_eq!(s.host, "github.com");
assert_eq!(s.owner, "acme");
assert_eq!(s.repo, "agents");
assert_eq!(s.name, "github.com/acme/agents");
assert_eq!(s.url, "ssh://git@github.com/acme/agents");
}
#[test]
fn ssh_url_with_userinfo_on_a_non_github_host() {
let s = parse_spec("ssh://git@ghe.corp/team/agents.git").unwrap();
assert_eq!(s.host, "ghe.corp");
assert_eq!(s.owner, "team");
assert_eq!(s.repo, "agents");
assert_eq!(s.name, "ghe.corp/team/agents");
assert_eq!(s.url, "ssh://git@ghe.corp/team/agents");
}
#[test]
fn ssh_url_without_userinfo_still_parses() {
let s = parse_spec("ssh://host/o/r").unwrap();
assert_eq!(s.host, "host");
assert_eq!(s.owner, "o");
assert_eq!(s.repo, "r");
assert_eq!(s.url, "ssh://host/o/r");
}
#[test]
fn https_url_with_userinfo_is_refused() {
let err = parse_spec("https://token@host/o/r").unwrap_err();
match err {
MindError::UnsafeRepoSpec {
part,
value,
reason,
..
} => {
assert_eq!(part, "host");
assert_eq!(value, "token@host");
assert!(
reason.contains("credential"),
"reason must name the credential: {reason}"
);
}
other => panic!("expected UnsafeRepoSpec naming host, got {other:?}"),
}
}
#[test]
fn ssh_item_link_keeps_userinfo_in_url() {
let s = parse_spec("ssh://git@host/o/r/tree/main/skills/foo").unwrap();
assert_eq!(s.name, "host/o/r#skills/foo");
assert_eq!(s.url, "ssh://git@host/o/r");
assert_eq!(s.item_path.as_deref(), Some("skills/foo"));
assert_eq!(s.base_identity(), "host/o/r");
}
#[test]
fn ssh_userinfo_control_character_is_rejected() {
let err = parse_spec("ssh://git\u{7}@host/o/r").unwrap_err();
match err {
MindError::UnsafeRepoSpec { part, reason, .. } => {
assert_eq!(part, "host");
assert!(
reason.contains("control character"),
"reason must name the control character: {reason}"
);
}
other => panic!("expected UnsafeRepoSpec naming host, got {other:?}"),
}
}
#[test]
fn ssh_authority_splits_on_the_last_at_not_the_first() {
let s = parse_spec("ssh://user@corp.example@git.host/o/r").unwrap();
assert_eq!(
s.host, "git.host",
"the host is the part after the LAST '@'"
);
assert_eq!(s.name, "git.host/o/r");
assert_eq!(
s.url, "ssh://user@corp.example@git.host/o/r",
"the FULL authority (both '@'s) must stay in url so git clone authenticates"
);
}
#[test]
fn ssh_empty_userinfo_is_rejected() {
let err = parse_spec("ssh://@host/o/r").unwrap_err();
match err {
MindError::UnsafeRepoSpec {
part,
value,
reason,
..
} => {
assert_eq!(part, "host");
assert_eq!(value, "@host", "value is the FULL authority");
assert!(
reason.contains("empty"),
"reason must name the empty userinfo: {reason}"
);
}
other => panic!("expected UnsafeRepoSpec naming host, got {other:?}"),
}
}
#[test]
fn ssh_host_after_userinfo_is_still_validated_like_any_other_host() {
for (spec, expect_reason) in [
("ssh://git@/o/r", "is empty"),
("ssh://git@ho#st/o/r", "'#'"),
("ssh://git@ho\\st/o/r", "contains a path separator"),
("ssh://git@../o/r", "is a relative path component"),
] {
match parse_spec(spec) {
Err(MindError::UnsafeRepoSpec { part, reason, .. }) => {
assert_eq!(part, "host", "{spec} must be refused on the host part");
assert!(
reason.contains(expect_reason),
"{spec}: reason must be {expect_reason:?}, got {reason:?}"
);
}
other => panic!("{spec} must be refused as an unsafe host, got {other:?}"),
}
}
}
#[test]
fn ssh_userinfo_never_reaches_the_clone_path() {
let (base, paths) = tmp_paths_src();
let s = parse_spec("ssh://git@git.host/o/r").unwrap();
let dir = s.clone_dir(&paths);
let shown = dir.display().to_string();
assert!(
!shown.contains("git@"),
"the clone path must not embed the userinfo: {shown}"
);
assert!(
dir.ends_with("git.host/o/r"),
"clone path must be sources/<host>/<owner>/<repo>: {shown}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn non_ssh_userinfo_is_refused_for_every_scheme_including_deep_links() {
for spec in [
"https://token@host/o/r",
"http://user:pw@host/o/r",
"git://token@host/o/r",
"https://token@host/o/r/tree/main/skills/foo",
"http://user:pw@host/o/r/blob/main/skills/foo/SKILL.md",
] {
match parse_spec(spec) {
Err(MindError::UnsafeRepoSpec {
part,
value,
reason,
..
}) => {
assert_eq!(part, "host", "{spec}");
assert!(
value.contains('@'),
"{spec}: value must be the full authority, got {value:?}"
);
assert!(
reason.contains("credential"),
"{spec}: reason must name the credential, got {reason:?}"
);
}
other => panic!("{spec} must be refused as an embedded credential, got {other:?}"),
}
}
}
#[test]
fn parses_local_path() {
let s = parse_spec("/home/user/dev/agents").unwrap();
assert_eq!(s.host, "local");
assert_eq!(s.owner, "dev"); assert_eq!(s.repo, "agents");
assert_eq!(s.name, "local/dev/agents");
assert_eq!(s.url, "/home/user/dev/agents");
}
#[test]
fn rejects_unsafe_identity_parts() {
let cases = [
("git@../../elsewhere:owner/repo", "host"),
("git@..:owner/repo", "host"),
("git@evil/host:owner/repo", "host"),
("git@github.com/acme/agents@x:o/r", "host"),
("git@evil#frag:owner/repo", "host"),
("github:../x", "owner"),
];
for (spec, part) in cases {
match parse_spec(spec) {
Err(MindError::UnsafeRepoSpec { part: p, .. }) => {
assert_eq!(p, part, "{spec}: wrong part named")
}
other => panic!("{spec}: expected UnsafeRepoSpec on {part}, got {other:?}"),
}
}
}
#[test]
fn absolutize_resolves_parent_dir_lexically_before_identity_derivation() {
let s = parse_spec("/src/parent/..").expect(
"STO-72: the '..' is resolved away before repo/owner derivation, so this now parses",
);
assert_eq!(s.url, "/src", "the '..' must be resolved lexically");
assert_eq!(s.repo, "src");
}
#[test]
fn absolutize_against_joins_relative_paths_onto_the_given_base() {
let base = Path::new("/some/base/dir");
assert_eq!(
absolutize_against(base, "./foo"),
"/some/base/dir/foo",
"a './' path joins onto base"
);
assert_eq!(
absolutize_against(base, "../foo"),
"/some/base/foo",
"a '../' path resolves one level up from base"
);
assert_eq!(
absolutize_against(base, "/abs/path"),
"/abs/path",
"an already-absolute path is left unchanged (base is not joined)"
);
assert_eq!(
absolutize_against(base, "a/./b/../c"),
"/some/base/dir/a/c",
"internal '.'/'..' components are resolved too"
);
}
#[test]
fn absolutize_against_keeps_a_leading_parent_dir_with_nothing_to_cancel() {
let base = Path::new("/");
assert_eq!(absolutize_against(base, "../.."), "/../..");
}
#[test]
fn relative_dot_slash_local_path_does_not_double_the_local_owner() {
let cwd = std::env::current_dir().expect("must read cwd");
let parent_name = cwd
.file_name()
.expect("cwd must have a basename")
.to_string_lossy()
.into_owned();
let s = parse_spec("./foo").expect("a relative local path must still parse");
assert_eq!(s.repo, "foo");
assert_eq!(
s.owner, parent_name,
"owner must be the real parent directory name, not the 'local' placeholder"
);
assert_eq!(s.name, format!("local/{parent_name}/foo"));
assert_eq!(s.url, cwd.join("foo").to_string_lossy());
}
#[test]
fn relative_parent_dir_local_path_derives_owner_from_the_real_grandparent() {
let cwd = std::env::current_dir().expect("must read cwd");
let parent = cwd.parent().expect("cwd must have a parent");
let grandparent_name = parent
.file_name()
.expect("parent must have a basename")
.to_string_lossy()
.into_owned();
let s = parse_spec("../foo").expect("a '../' local path must parse");
assert_eq!(s.repo, "foo");
assert_eq!(
s.owner, grandparent_name,
"owner must be the real grandparent directory, not the 'local' placeholder"
);
assert_eq!(s.name, format!("local/{grandparent_name}/foo"));
assert_eq!(
s.url,
parent.join("foo").to_string_lossy(),
"the persisted url must be the absolute resolution, not the literal '../foo'"
);
}
#[test]
fn relative_file_url_item_link_is_absolutized_before_identity_derivation() {
let cwd = std::env::current_dir().expect("must read cwd");
let parent_name = cwd
.file_name()
.expect("cwd must have a basename")
.to_string_lossy()
.into_owned();
let s = parse_spec("file://./foo/tree/main/skills/greet")
.expect("a relative file:// item link must parse");
assert_eq!(s.repo, "foo");
assert_eq!(
s.owner, parent_name,
"owner must be the real parent directory, not the 'local' placeholder"
);
assert_eq!(s.name, format!("local/{parent_name}/foo#skills/greet"));
assert_eq!(s.item_path.as_deref(), Some("skills/greet"));
assert_eq!(
s.url,
cwd.join("foo").to_string_lossy(),
"the link's repo url must be absolutized, not the literal './foo'"
);
}
#[test]
fn at_marker_is_allowed_in_owner_only() {
let s = parse_spec("/src/proj@v2/agents").unwrap();
assert_eq!(s.owner, "proj@v2");
assert_eq!(s.base_identity(), "local/proj@v2/agents");
}
#[test]
fn at_and_hash_are_rejected_in_an_item_link_path() {
for url in [
"https://github.com/acme/repo/tree/main/skills/foo@bar",
"file:///src/acme/repo/tree/main/skills/foo@bar",
] {
match parse_spec(url) {
Err(MindError::BadItemLink { reason, .. }) => assert!(
reason.contains("identity markers"),
"{url}: reason should name the marker rule, got {reason:?}"
),
other => panic!("{url}: expected BadItemLink, got {other:?}"),
}
}
match parse_spec("file:///src/acme/repo/tree/main/skills/foo#bar") {
Err(MindError::BadItemLink { .. }) => {}
other => panic!("expected BadItemLink for a `#` in a local link path, got {other:?}"),
}
let stripped = parse_spec("https://github.com/acme/repo/tree/main/skills/foo#bar").unwrap();
assert_eq!(
stripped.name, "github.com/acme/repo#skills/foo",
"a remote URL's fragment is stripped, not smuggled into the path"
);
let ok = parse_spec("https://github.com/acme/repo/tree/main/skills/foo").unwrap();
assert_eq!(ok.name, "github.com/acme/repo#skills/foo");
}
#[test]
fn curdir_segments_normalize_out_of_an_item_link_path() {
let plain = parse_spec("https://github.com/acme/repo/tree/main/skills/foo").unwrap();
for url in [
"https://github.com/acme/repo/tree/main/./skills/foo",
"https://github.com/acme/repo/tree/main/skills/./foo",
"https://github.com/acme/repo/tree/main/./skills/./foo",
"https://github.com/acme/repo/blob/main/skills/./foo/SKILL.md",
] {
let s = parse_spec(url).unwrap();
assert_eq!(
s.name, plain.name,
"{url}: must normalize to the plain instance identity"
);
assert_eq!(s.item_path.as_deref(), Some("skills/foo"), "{url}");
}
match parse_spec("https://github.com/acme/repo/tree/main/./.") {
Err(MindError::BadItemLink { reason, .. }) => assert!(
reason.contains("missing the item path"),
"an all-dots path must report a missing item path, got {reason:?}"
),
other => panic!("expected BadItemLink for an all-dots path, got {other:?}"),
}
}
#[test]
fn at_and_hash_are_rejected_in_repo() {
match parse_spec("/src/parent/blessed@evil") {
Err(MindError::UnsafeRepoSpec { part, value, .. }) => {
assert_eq!(part, "repo");
assert_eq!(value, "blessed@evil");
}
other => panic!("expected UnsafeRepoSpec on repo, got {other:?}"),
}
match parse_spec("/src/parent/blessed#evil") {
Err(MindError::UnsafeRepoSpec { part, value, .. }) => {
assert_eq!(part, "repo");
assert_eq!(value, "blessed#evil");
}
other => panic!("expected UnsafeRepoSpec on repo, got {other:?}"),
}
match parse_spec("git@github.com:owner/blessed@evil") {
Err(MindError::UnsafeRepoSpec { part, .. }) => assert_eq!(part, "repo"),
other => panic!("expected UnsafeRepoSpec on repo, got {other:?}"),
}
}
#[test]
fn hash_is_rejected_in_owner() {
match parse_spec("github:evil#owner/repo") {
Err(MindError::UnsafeRepoSpec { part, value, .. }) => {
assert_eq!(part, "owner");
assert_eq!(value, "evil#owner");
}
other => panic!("expected UnsafeRepoSpec on owner, got {other:?}"),
}
}
#[test]
fn distinct_sources_no_longer_collide_on_repo_at_alias_suffix() {
let (base, paths) = tmp_paths_src();
let literal = parse_spec("https://forge.example.com/owner/foo@bar");
let mut aliased =
parse_spec("https://forge.example.com/owner/foo").expect("plain repo must parse");
aliased.apply_alias(Some("bar".into()));
match literal {
Err(MindError::UnsafeRepoSpec { part, .. }) => {
assert_eq!(
part, "repo",
"a literal `@` in the repo name must be refused as an unsafe repo part"
);
}
Ok(s) => panic!(
"unfixed behavior: a repo named `foo@bar` parsed successfully to identity \
{:?} (clone dir {:?}), colliding with the aliased instance's identity {:?} \
(clone dir {:?}) -- this is the exact collision STO-64 closes",
s.compute_name(),
s.clone_dir(&paths),
aliased.compute_name(),
aliased.clone_dir(&paths)
),
other => panic!("expected UnsafeRepoSpec on repo, got {other:?}"),
}
assert_eq!(aliased.compute_name(), "forge.example.com/owner/foo@bar");
assert!(
aliased
.clone_dir(&paths)
.ends_with("forge.example.com/owner/foo@bar")
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn rejects_garbage_specs() {
for bad in ["", "noslash", "trailing/", "/leading-only-after-strip"] {
if bad.starts_with('/') {
continue;
}
assert!(parse_spec(bad).is_err(), "expected error for {bad:?}");
}
}
#[test]
fn ssh_url_uses_the_git_at_form() {
let s = parse_spec("james/agents").unwrap();
assert_eq!(s.ssh_url(), "git@github.com:james/agents");
}
#[test]
fn prefer_ssh_rewrites_https_remotes_only() {
let mut s = parse_spec("james/agents").unwrap();
s.prefer_ssh(true);
assert_eq!(s.url, "git@github.com:james/agents");
let mut g = parse_spec("git@github.com:foo/bar.git").unwrap();
let before = g.url.clone();
g.prefer_ssh(true);
assert_eq!(g.url, before);
let mut l = parse_spec("/tmp/x").unwrap();
let lbefore = l.url.clone();
l.prefer_ssh(true);
assert_eq!(l.url, lbefore);
let mut h = parse_spec("james/agents").unwrap();
h.prefer_ssh(false);
assert_eq!(h.url, "https://github.com/james/agents");
}
#[test]
fn prefer_ssh_rewrites_plain_http_url() {
let mut s = parse_spec("https://example.com/owner/repo").unwrap();
s.url = "http://example.com/owner/repo".to_string();
s.host = "example.com".to_string();
s.prefer_ssh(true);
assert_eq!(
s.url, "git@example.com:owner/repo",
"http:// remote must be rewritten to SSH form under prefer_ssh"
);
let mut s2 = parse_spec("https://example.com/owner/repo").unwrap();
s2.url = "http://example.com/owner/repo".to_string();
s2.host = "example.com".to_string();
s2.prefer_ssh(false);
assert_eq!(
s2.url, "http://example.com/owner/repo",
"prefer_ssh=false must leave an http:// URL unchanged"
);
}
#[test]
fn compare_url_github_com_produces_correct_link() {
let gh = parse_spec("foo/bar").unwrap();
assert_eq!(
gh.compare_url("aaaa", "bbbb").as_deref(),
Some("https://github.com/foo/bar/compare/aaaa...bbbb")
);
}
#[test]
fn compare_url_ghes_host_produces_same_shape() {
let ghes = parse_spec("https://github.example.com/acme/tools").unwrap();
assert_eq!(
ghes.compare_url("deadbeef", "cafebabe").as_deref(),
Some("https://github.example.com/acme/tools/compare/deadbeef...cafebabe")
);
let corp = parse_spec("https://git.corp.internal/devtools/scripts").unwrap();
assert_eq!(
corp.compare_url("old", "new").as_deref(),
Some("https://git.corp.internal/devtools/scripts/compare/old...new")
);
}
#[test]
fn compare_url_gitlab_hosts_yield_none() {
let gl = parse_spec("https://gitlab.com/org/project").unwrap();
assert_eq!(gl.compare_url("aaaa", "bbbb"), None, "gitlab.com");
let self_hosted = parse_spec("https://gitlab.corp.example.com/org/project").unwrap();
assert_eq!(
self_hosted.compare_url("aaaa", "bbbb"),
None,
"self-hosted gitlab"
);
}
#[test]
fn compare_url_bitbucket_hosts_yield_none() {
let bb = parse_spec("https://bitbucket.org/org/repo").unwrap();
assert_eq!(bb.compare_url("aaaa", "bbbb"), None, "bitbucket.org");
}
#[test]
fn compare_url_ssh_remote_yields_none() {
let ssh = parse_spec("git@github.com:foo/bar.git").unwrap();
assert_eq!(ssh.compare_url("aaaa", "bbbb"), None);
}
#[test]
fn compare_url_local_path_yields_none() {
let local = parse_spec("/home/user/dev/agents").unwrap();
assert_eq!(local.compare_url("aaaa", "bbbb"), None);
let file_url = parse_spec("file:///home/user/dev/agents").unwrap();
assert_eq!(file_url.compare_url("aaaa", "bbbb"), None);
}
#[test]
fn browse_url_github_com_produces_tree_link() {
let gh = parse_spec("foo/bar").unwrap();
assert_eq!(
gh.browse_url("abc1234").as_deref(),
Some("https://github.com/foo/bar/tree/abc1234")
);
}
#[test]
fn browse_url_ghes_host_produces_same_shape() {
let ghes = parse_spec("https://github.example.com/acme/tools").unwrap();
assert_eq!(
ghes.browse_url("deadbeef").as_deref(),
Some("https://github.example.com/acme/tools/tree/deadbeef")
);
let corp = parse_spec("https://git.corp.internal/devtools/scripts").unwrap();
assert_eq!(
corp.browse_url("cafebabe").as_deref(),
Some("https://git.corp.internal/devtools/scripts/tree/cafebabe")
);
}
#[test]
fn browse_url_gitlab_hosts_yield_none() {
let gl = parse_spec("https://gitlab.com/org/project").unwrap();
assert_eq!(gl.browse_url("abc1234"), None, "gitlab.com");
let self_hosted = parse_spec("https://gitlab.corp.example.com/org/project").unwrap();
assert_eq!(
self_hosted.browse_url("abc1234"),
None,
"self-hosted gitlab"
);
}
#[test]
fn browse_url_bitbucket_hosts_yield_none() {
let bb = parse_spec("https://bitbucket.org/org/repo").unwrap();
assert_eq!(bb.browse_url("abc1234"), None, "bitbucket.org");
}
#[test]
fn browse_url_ssh_remote_yields_none() {
let ssh = parse_spec("git@github.com:foo/bar.git").unwrap();
assert_eq!(ssh.browse_url("abc1234"), None);
}
#[test]
fn browse_url_local_path_yields_none() {
let local = parse_spec("/home/user/dev/agents").unwrap();
assert_eq!(local.browse_url("abc1234"), None);
let file_url = parse_spec("file:///home/user/dev/agents").unwrap();
assert_eq!(file_url.browse_url("abc1234"), None);
}
#[test]
fn browse_url_renders_pinned_tree_line_in_consent_disclosure() {
let gh = parse_spec("foo/bar").unwrap();
let commit = "abc1234";
let url = gh.browse_url(commit);
assert_eq!(
url.as_deref(),
Some("https://github.com/foo/bar/tree/abc1234"),
"precondition: github source derives a tree URL"
);
let text = crate::hook::disclosure_text(
"github.com/foo/bar",
"main",
commit,
"/home/user/.mind/sources/github.com/foo/bar",
"make install",
None,
url.as_deref(),
);
assert!(
text.contains(" Browse: https://github.com/foo/bar/tree/abc1234\n"),
"consent disclosure must render the derived commit-pinned browse line; got: {text}"
);
}
#[test]
fn browse_url_none_suppresses_browse_line_in_consent_disclosure() {
let local = parse_spec("/home/user/dev/agents").unwrap();
let url = local.browse_url("abc1234");
assert_eq!(url, None, "precondition: local path derives no browse URL");
let text = crate::hook::disclosure_text(
"/home/user/dev/agents",
"local",
"abc1234",
"/home/user/dev/agents",
"make install",
None,
url.as_deref(),
);
assert!(
!text.contains("Browse:"),
"a None browse_url must suppress the Browse line end-to-end; got: {text}"
);
}
#[test]
fn pin_serde_round_trips() {
let cases = [
(Pin::DefaultBranch, r#"{"kind":"default-branch"}"#),
(
Pin::FollowBranch("main".into()),
r#"{"kind":"follow-branch","value":"main"}"#,
),
(Pin::Tag("v1.0".into()), r#"{"kind":"tag","value":"v1.0"}"#),
(
Pin::Ref("abc1234".into()),
r#"{"kind":"ref","value":"abc1234"}"#,
),
];
for (pin, expected_json) in &cases {
let json = serde_json::to_string(pin).unwrap();
assert_eq!(json, *expected_json, "serialization mismatch for {pin:?}");
let roundtripped: Pin = serde_json::from_str(&json).unwrap();
assert_eq!(roundtripped, *pin, "round-trip failed for {pin:?}");
}
let src_json = r#"{
"name":"local/a/b","url":"/a/b","host":"local","owner":"a","repo":"b"
}"#;
let src: Source = serde_json::from_str(src_json).unwrap();
assert_eq!(
src.pin,
Pin::DefaultBranch,
"absent pin should default to DefaultBranch"
);
}
#[test]
fn flat_skills_round_trips_and_defaults_false() {
let mut s = parse_spec("acme/tools").unwrap();
assert!(!s.flat_skills, "default must be false");
s.flat_skills = true;
let json = serde_json::to_string(&s).unwrap();
let back: Source = serde_json::from_str(&json).unwrap();
assert!(back.flat_skills, "flat_skills=true must round-trip");
let legacy = r#"{
"name":"local/a/b","url":"/a/b","host":"local","owner":"a","repo":"b"
}"#;
let src: Source = serde_json::from_str(legacy).unwrap();
assert!(!src.flat_skills, "absent flat_skills must default to false");
}
#[test]
fn install_hook_fields_round_trip_and_default_absent() {
let src_json = r#"{
"name":"local/a/b","url":"/a/b","host":"local","owner":"a","repo":"b"
}"#;
let src: Source = serde_json::from_str(src_json).unwrap();
assert_eq!(
src.install_hook, None,
"absent install_hook should default to None"
);
assert_eq!(
src.install_hook_commit, None,
"absent install_hook_commit should default to None"
);
assert!(
src.install_hooks.is_empty(),
"absent install_hooks should default to empty"
);
let mut s = parse_spec("acme/tools").unwrap();
s.install_hook = Some("make install".into());
s.install_hook_commit = Some("abc1234".into());
s.migrate_legacy_hook();
assert_eq!(s.install_hook, None, "legacy field cleared after migration");
assert_eq!(
s.install_hook_commit, None,
"legacy commit field cleared after migration"
);
assert_eq!(s.install_hooks.len(), 1, "hook migrated into install_hooks");
assert_eq!(s.install_hooks[0].command, "make install");
assert_eq!(s.install_hooks[0].ran_at.as_deref(), Some("abc1234"));
}
#[test]
fn recorded_hook_serde_round_trip_with_ran_at_some() {
let hook = RecordedSourceHook::install("make install", Some("deadbeef".into()));
let json = serde_json::to_string(&hook).unwrap();
let back: RecordedSourceHook = serde_json::from_str(&json).unwrap();
assert_eq!(
back, hook,
"RecordedSourceHook with ran_at=Some did not round-trip"
);
}
#[test]
fn recorded_hook_serde_round_trip_with_ran_at_none() {
let hook = RecordedSourceHook::install("make install", None);
let json = serde_json::to_string(&hook).unwrap();
let back: RecordedSourceHook = serde_json::from_str(&json).unwrap();
assert_eq!(
back, hook,
"RecordedSourceHook with ran_at=None did not round-trip"
);
}
#[test]
fn an_old_format_record_with_no_event_loads_as_an_install_record() {
let legacy = r#"{
"name":"local/a/b","url":"/a/b","host":"local","owner":"a","repo":"b",
"commit":"bbb",
"install_hooks":[{"command":"make setup","ran_at":"aaa"}]
}"#;
let src: Source = serde_json::from_str(legacy).unwrap();
assert_eq!(src.install_hooks.len(), 1, "the legacy entry must load");
let rec = &src.install_hooks[0];
assert_eq!(rec.event, None, "the stored event is absent");
assert_eq!(
rec.event(),
RecordedEvent::Install,
"an eventless record reads as an install record"
);
assert_eq!(rec.origin, None, "a legacy record has no origin");
assert!(!rec.baseline, "a legacy record is a run, not a baseline");
let declared = vec!["make setup".to_string()];
assert_eq!(
src.pending_install_hooks(Some("bbb"), &declared, true)
.len(),
1,
"a legacy record that advanced past its run-commit is pending"
);
}
#[test]
fn install_hooks_vec_round_trips_on_source() {
let mut s = parse_spec("acme/tools").unwrap();
s.install_hooks = vec![
RecordedSourceHook::install("make setup", Some("aaa".into())),
RecordedSourceHook::install("make install", None),
];
let json = serde_json::to_string(&s).unwrap();
let back: Source = serde_json::from_str(&json).unwrap();
assert_eq!(back.install_hooks.len(), 2);
assert_eq!(back.install_hooks[0].command, "make setup");
assert_eq!(back.install_hooks[0].ran_at.as_deref(), Some("aaa"));
assert_eq!(back.install_hooks[1].command, "make install");
assert_eq!(back.install_hooks[1].ran_at, None);
}
#[test]
fn migrate_legacy_hook_with_commit() {
let legacy_json = r#"{
"name":"local/a/b","url":"/a/b","host":"local","owner":"a","repo":"b",
"install_hook":"./setup.sh",
"install_hook_commit":"cafebabe"
}"#;
let mut src: Source = serde_json::from_str(legacy_json).unwrap();
assert_eq!(src.install_hook.as_deref(), Some("./setup.sh"));
assert_eq!(src.install_hook_commit.as_deref(), Some("cafebabe"));
assert!(src.install_hooks.is_empty());
src.migrate_legacy_hook();
assert_eq!(src.install_hooks.len(), 1, "hook should have been migrated");
assert_eq!(src.install_hooks[0].command, "./setup.sh");
assert_eq!(src.install_hooks[0].ran_at.as_deref(), Some("cafebabe"));
assert_eq!(src.install_hook, None, "legacy field should be cleared");
assert_eq!(
src.install_hook_commit, None,
"legacy commit should be cleared"
);
let json = serde_json::to_string(&src).unwrap();
assert!(
!json.contains("install_hook_commit"),
"legacy commit must not re-emit"
);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
v.get("install_hook").is_none(),
"install_hook must not re-emit"
);
}
#[test]
fn migrate_legacy_hook_without_commit() {
let legacy_json = r#"{
"name":"local/a/b","url":"/a/b","host":"local","owner":"a","repo":"b",
"install_hook":"./setup.sh"
}"#;
let mut src: Source = serde_json::from_str(legacy_json).unwrap();
src.migrate_legacy_hook();
assert_eq!(src.install_hooks.len(), 1);
assert_eq!(src.install_hooks[0].command, "./setup.sh");
assert_eq!(
src.install_hooks[0].ran_at, None,
"skipped hook: ran_at should be None"
);
assert_eq!(src.install_hook, None);
}
#[test]
fn migrate_legacy_hook_is_idempotent() {
let legacy_json = r#"{
"name":"local/a/b","url":"/a/b","host":"local","owner":"a","repo":"b",
"install_hook":"./setup.sh",
"install_hook_commit":"deadbeef"
}"#;
let mut src: Source = serde_json::from_str(legacy_json).unwrap();
src.migrate_legacy_hook();
src.migrate_legacy_hook();
assert_eq!(
src.install_hooks.len(),
1,
"idempotent: should not duplicate"
);
assert_eq!(src.install_hooks[0].command, "./setup.sh");
}
#[test]
fn migrate_legacy_hook_noop_when_install_hooks_already_populated() {
let mut src = parse_spec("acme/tools").unwrap();
src.install_hooks = vec![RecordedSourceHook::install(
"pre-existing",
Some("aaa".into()),
)];
src.install_hook = Some("./old-hook.sh".into());
src.install_hook_commit = Some("bbb".into());
src.migrate_legacy_hook();
assert_eq!(src.install_hooks.len(), 1, "must not add a second hook");
assert_eq!(src.install_hooks[0].command, "pre-existing");
assert_eq!(
src.install_hook, None,
"legacy field cleared even when skipped"
);
assert_eq!(
src.install_hook_commit, None,
"legacy commit cleared even when skipped"
);
}
#[test]
fn absent_install_hooks_defaults_to_empty() {
let src_json = r#"{
"name":"local/a/b","url":"/a/b","host":"local","owner":"a","repo":"b"
}"#;
let src: Source = serde_json::from_str(src_json).unwrap();
assert!(
src.install_hooks.is_empty(),
"install_hooks should default to empty"
);
}
#[test]
fn pending_install_hooks_returns_unrun_and_advanced() {
let mut src = parse_spec("acme/tools").unwrap();
src.install_hooks = vec![
RecordedSourceHook::install("hook-a", None),
RecordedSourceHook::install("hook-b", Some("aaa".into())),
RecordedSourceHook::install("hook-c", Some("bbb".into())),
];
let declared = vec![
"hook-a".to_string(),
"hook-b".to_string(),
"hook-c".to_string(),
];
let pending = src.pending_install_hooks(Some("bbb"), &declared, true);
assert_eq!(pending.len(), 2);
assert_eq!(pending[0].command, "hook-a");
assert_eq!(pending[1].command, "hook-b");
}
#[test]
fn pending_install_hooks_all_pending_when_no_current_commit() {
let mut src = parse_spec("acme/tools").unwrap();
src.install_hooks = vec![
RecordedSourceHook::install("hook-a", None),
RecordedSourceHook::install("hook-b", Some("aaa".into())),
];
let declared = vec!["hook-a".to_string(), "hook-b".to_string()];
let pending = src.pending_install_hooks(None, &declared, true);
assert_eq!(pending.len(), 2);
assert_eq!(pending[0].command, "hook-a");
assert_eq!(pending[1].command, "hook-b");
}
#[test]
fn pending_install_hooks_drops_records_the_clone_no_longer_declares() {
let mut src = parse_spec("acme/tools").unwrap();
let mut curated = RecordedSourceHook::install("curated.sh", None);
curated.origin = Some(HookOrigin::Curated);
let mut overridden = RecordedSourceHook::install("mine.sh", None);
overridden.origin = Some(HookOrigin::Override);
src.install_hooks = vec![
RecordedSourceHook::install("still-declared", None),
RecordedSourceHook::install("withdrawn", None),
curated,
overridden,
];
let declared = vec!["still-declared".to_string()];
let pending: Vec<&str> = src
.pending_install_hooks(Some("bbb"), &declared, true)
.into_iter()
.map(|h| h.command.as_str())
.collect();
assert_eq!(pending, vec!["still-declared", "curated.sh", "mine.sh"]);
let pending: Vec<&str> = src
.pending_install_hooks(Some("bbb"), &declared, false)
.into_iter()
.map(|h| h.command.as_str())
.collect();
assert_eq!(pending, vec!["still-declared", "mine.sh"]);
}
#[test]
fn a_command_recorded_for_two_events_keeps_two_independent_records() {
let mut src = parse_spec("acme/tools").unwrap();
let mut update = RecordedSourceHook::install("make setup", None);
update.event = Some(RecordedEvent::Update);
src.install_hooks = vec![
RecordedSourceHook::install("make setup", Some("bbb".into())),
update,
];
let declared = vec!["make setup".to_string()];
assert!(
src.pending_install_hooks(Some("bbb"), &declared, true)
.is_empty(),
"the install record ran at the current commit, so nothing pends"
);
let update_rec = src
.install_hooks
.iter()
.find(|h| h.is("make setup", RecordedEvent::Update))
.expect("the update record is addressable by (command, event)");
assert!(
update_rec.pending(Some("bbb")),
"the update record is untouched by the install record's run"
);
}
#[test]
fn override_command_reads_back_the_recorded_consumer_override() {
let mut src = parse_spec("acme/tools").unwrap();
assert_eq!(src.override_command(), None);
let mut overridden = RecordedSourceHook::install("./mine.sh", None);
overridden.origin = Some(HookOrigin::Override);
src.install_hooks = vec![RecordedSourceHook::install("theirs.sh", None), overridden];
assert_eq!(src.override_command(), Some("./mine.sh"));
}
#[test]
fn origin_and_version_round_trip() {
let mut s = parse_spec("acme/tools").unwrap();
s.origin = Some(ManifestOrigin::ClaudePlugin);
s.plugin_version = Some("1.2.3".into());
let json = serde_json::to_string(&s).unwrap();
assert!(
json.contains("\"claude-plugin\""),
"serialized JSON must contain the kebab-case origin label"
);
assert!(
json.contains("\"1.2.3\""),
"serialized JSON must contain the plugin version"
);
let back: Source = serde_json::from_str(&json).unwrap();
assert_eq!(back.origin, Some(ManifestOrigin::ClaudePlugin));
assert_eq!(back.plugin_version.as_deref(), Some("1.2.3"));
}
#[test]
fn absent_origin_defaults_to_none_and_omits_keys() {
let legacy = r#"{"name":"local/a/b","url":"/a/b","host":"local","owner":"a","repo":"b"}"#;
let src: Source = serde_json::from_str(legacy).unwrap();
assert_eq!(src.origin, None, "absent origin must default to None");
assert_eq!(
src.plugin_version, None,
"absent plugin_version must default to None"
);
let fresh = parse_spec("acme/tools").unwrap();
let json = serde_json::to_string(&fresh).unwrap();
assert!(
!json.contains("\"origin\""),
"origin must be absent from JSON when None"
);
assert!(
!json.contains("\"plugin_version\""),
"plugin_version must be absent from JSON when None"
);
}
use std::sync::atomic::{AtomicU32, Ordering};
static SRC_N: AtomicU32 = AtomicU32::new(0);
#[test]
fn malformed_sources_json_is_a_json_error_naming_the_file() {
let (base, paths) = tmp_paths_src();
std::fs::write(base.join("sources.json"), "{ not json at all").unwrap();
match Registry::load(&paths) {
Err(MindError::Json { what, .. }) => assert_eq!(what, "sources.json"),
other => panic!("expected a Json error naming sources.json, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&base);
}
fn tmp_paths_src() -> (std::path::PathBuf, Paths) {
let n = SRC_N.fetch_add(1, Ordering::SeqCst);
let base =
std::env::temp_dir().join(format!("mind-sources-ver-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let paths = Paths {
mind_home: base.clone(),
claude_home: base.join("claude"),
};
(base, paths)
}
#[test]
fn registry_missing_version_is_treated_as_one() {
let (base, paths) = tmp_paths_src();
std::fs::write(base.join("sources.json"), r#"{"sources":[]}"#).unwrap();
let r = Registry::load(&paths).expect("must load without version field");
assert!(r.sources.is_empty(), "sources must be empty: {r:?}");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn registry_version_one_loads_ok() {
let (base, paths) = tmp_paths_src();
std::fs::write(base.join("sources.json"), r#"{"version":1,"sources":[]}"#).unwrap();
let r = Registry::load(&paths).expect("version 1 must load");
assert!(
r.sources.is_empty(),
"version 1 must load successfully: {r:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn registry_too_new_version_is_state_too_new_error() {
let (base, paths) = tmp_paths_src();
std::fs::write(base.join("sources.json"), r#"{"version":42,"sources":[]}"#).unwrap();
let err = Registry::load(&paths).unwrap_err();
match err {
MindError::StateTooNew {
what,
found,
supported,
} => {
assert_eq!(what, "sources.json");
assert_eq!(found, 42);
assert_eq!(supported, REGISTRY_VERSION);
}
other => panic!("expected StateTooNew, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn revalidate_sources_drops_offending_entry_and_warns_naming_the_part() {
let mut bad = parse_spec("acme/victim").unwrap();
bad.host = "..".to_string();
bad.owner = "..".to_string();
bad.repo = "victim".to_string();
bad.name = "../../victim".to_string();
let name_before = bad.name.clone();
let mut warnings: Vec<String> = Vec::new();
let kept = revalidate_sources(vec![bad], |msg| warnings.push(msg.to_string()));
assert!(kept.is_empty(), "the offending entry must be dropped");
assert_eq!(warnings.len(), 1, "exactly one warning must be emitted");
assert!(
warnings[0].contains("sources.json"),
"warning must name sources.json: {}",
warnings[0]
);
assert!(
warnings[0].contains(&name_before),
"warning must name the entry: {}",
warnings[0]
);
assert!(
warnings[0].contains("host"),
"warning must name the offending part (host, checked first): {}",
warnings[0]
);
}
#[test]
fn revalidate_sources_keeps_valid_entries_and_warns_for_none() {
let good = parse_spec("acme/agents").unwrap();
let mut warnings: Vec<String> = Vec::new();
let kept = revalidate_sources(vec![good], |msg| warnings.push(msg.to_string()));
assert_eq!(kept.len(), 1, "a valid entry must be kept");
assert!(warnings.is_empty(), "no warning for a valid entry");
}
#[test]
fn revalidate_rejects_unsafe_as_alias_and_pin_ref() {
let mut aliased = parse_spec("acme/agents").unwrap();
aliased.as_alias = Some("../evil".to_string());
assert!(
aliased.revalidate().is_err(),
"an unsafe as_alias must fail revalidation"
);
let mut pinned = parse_spec("acme/agents").unwrap();
pinned.pin = Pin::FollowBranch("-evil".to_string());
assert!(
pinned.revalidate().is_err(),
"an unsafe pin ref value must fail revalidation"
);
let default_pin = parse_spec("acme/agents").unwrap();
assert!(default_pin.revalidate().is_ok());
}
#[test]
fn registry_load_drops_a_pre_existing_unsafe_entry() {
let (base, paths) = tmp_paths_src();
std::fs::write(
base.join("sources.json"),
r#"{"version":1,"sources":[
{"name":"../../victim","url":"https://x/y/victim","host":"..","owner":"..","repo":"victim"},
{"name":"github.com/acme/agents","url":"https://github.com/acme/agents","host":"github.com","owner":"acme","repo":"agents"}
]}"#,
)
.unwrap();
let reg = Registry::load(&paths).expect("load must succeed despite the bad entry");
assert_eq!(
reg.sources.len(),
1,
"the unsafe entry must be dropped, the safe one kept: {reg:?}"
);
assert_eq!(reg.sources[0].name, "github.com/acme/agents");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn revalidate_names_whichever_single_part_is_unsafe() {
for (part, mutate) in [
(
"host",
(|s: &mut Source| s.host = "..".to_string()) as fn(&mut Source),
),
("owner", |s: &mut Source| s.owner = "ow#ner".to_string()),
("repo", |s: &mut Source| s.repo = "..".to_string()),
("as_alias", |s: &mut Source| {
s.as_alias = Some("../evil".to_string())
}),
("pin", |s: &mut Source| {
s.pin = Pin::Tag("--upload-pack=evil".to_string())
}),
] {
let mut src = parse_spec("acme/agents").unwrap();
mutate(&mut src);
let mut warnings: Vec<String> = Vec::new();
let kept = revalidate_sources(vec![src], |m| warnings.push(m.to_string()));
assert!(kept.is_empty(), "a bad {part} must drop the entry");
assert_eq!(warnings.len(), 1, "one warning per dropped entry ({part})");
assert!(
warnings[0].contains(&format!("its {part} part")),
"the warning must name the {part} part, got: {}",
warnings[0]
);
}
}
#[test]
fn revalidate_sources_warns_once_per_dropped_entry_and_keeps_the_survivors() {
let mut bad_a = parse_spec("acme/agents").unwrap();
bad_a.repo = "..".to_string();
bad_a.name = "a/bad".to_string();
let good = parse_spec("acme/good").unwrap();
let mut bad_b = parse_spec("acme/agents").unwrap();
bad_b.owner = "..".to_string();
bad_b.name = "b/bad".to_string();
let mut warnings: Vec<String> = Vec::new();
let kept = revalidate_sources(vec![bad_a, good, bad_b], |m| warnings.push(m.to_string()));
assert_eq!(kept.len(), 1, "only the valid entry survives");
assert_eq!(kept[0].name, "github.com/acme/good");
assert_eq!(warnings.len(), 2, "one warning per drop: {warnings:?}");
assert!(warnings.iter().any(|w| w.contains("a/bad")));
assert!(warnings.iter().any(|w| w.contains("b/bad")));
}
#[test]
fn registry_load_does_not_rewrite_sources_json_when_it_drops_an_entry() {
let (base, paths) = tmp_paths_src();
let file = base.join("sources.json");
let raw = r#"{"version":1,"sources":[
{"name":"../../victim","url":"https://x/y/victim","host":"..","owner":"..","repo":"victim"}
]}"#;
std::fs::write(&file, raw).unwrap();
let reg = Registry::load(&paths).expect("load must succeed");
assert!(reg.sources.is_empty(), "the entry is dropped in memory");
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
raw,
"load must not write the drop back to disk; it becomes permanent only on the next save"
);
reg.save(&paths).expect("save must succeed");
let after = std::fs::read_to_string(&file).unwrap();
assert!(
!after.contains("victim"),
"the drop must be persisted by the next save: {after}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn compute_name_composes_item_link_and_alias() {
let mut s = parse_spec("github:acme/agents").unwrap();
assert_eq!(s.compute_name(), "github.com/acme/agents");
s.apply_alias(Some("jk".into()));
assert_eq!(s.name, "github.com/acme/agents@jk");
s.apply_alias(Some(String::new()));
assert_eq!(s.name, "github.com/acme/agents");
assert_eq!(s.alias.as_deref(), Some(""));
let mut link = parse_spec("https://github.com/acme/agents/tree/main/skills/foo").unwrap();
assert_eq!(link.name, "github.com/acme/agents#skills/foo");
link.apply_alias(Some("jk".into()));
assert_eq!(link.name, "github.com/acme/agents#skills/foo@jk");
assert_eq!(link.base_identity(), "github.com/acme/agents");
}
#[test]
fn clone_dir_is_per_instance_for_an_alias() {
let (base, paths) = tmp_paths_src();
let mut s = parse_spec("github:acme/agents").unwrap();
let bare = s.clone_dir(&paths);
assert!(bare.ends_with("github.com/acme/agents"));
s.apply_alias(Some("jk".into()));
assert!(s.clone_dir(&paths).ends_with("github.com/acme/agents@jk"));
s.apply_alias(Some(String::new()));
assert_eq!(s.clone_dir(&paths), bare, "empty alias keeps the bare path");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn clone_dir_leaf_bare_shape_unchanged() {
assert_eq!(clone_dir_leaf("agents", None, None), "agents");
assert_eq!(clone_dir_leaf("agents", None, Some("")), "agents");
}
#[test]
fn clone_dir_leaf_alias_only_shape_unchanged() {
assert_eq!(clone_dir_leaf("agents", None, Some("jk")), "agents@jk");
}
#[test]
fn clone_dir_leaf_item_path_only_shape() {
assert_eq!(
clone_dir_leaf("agents", Some("skills/foo"), None),
"agents#skills%2Ffoo"
);
}
#[test]
fn clone_dir_leaf_item_path_and_alias_shape() {
assert_eq!(
clone_dir_leaf("agents", Some("skills/foo"), Some("jk")),
"agents#skills%2Ffoo@jk"
);
}
#[test]
fn clone_dir_leaf_distinguishes_two_item_paths_from_each_other_and_from_bare() {
let a = clone_dir_leaf("agents", Some("skills/a"), None);
let b = clone_dir_leaf("agents", Some("skills/b"), None);
let bare = clone_dir_leaf("agents", None, None);
assert_ne!(a, b, "two distinct item_paths must not share a leaf");
assert_ne!(
a, bare,
"an item-link leaf must not collide with the bare leaf"
);
assert_ne!(
b, bare,
"an item-link leaf must not collide with the bare leaf"
);
}
#[test]
fn clone_dir_leaf_percent_encodes_slash_and_percent_injectively() {
assert_eq!(
clone_dir_leaf("r", Some("a/b"), None),
"r#a%2Fb",
"slash must be percent-encoded"
);
assert_eq!(
clone_dir_leaf("r", Some("a%b"), None),
"r#a%25b",
"percent must itself be percent-encoded"
);
let leaf = clone_dir_leaf("r", Some("a/b/c"), None);
assert!(!leaf.contains('/'), "encoded leaf must have no '/': {leaf}");
}
#[test]
fn clone_dir_leaf_falls_back_to_a_hash_past_the_length_threshold() {
let short_path = "skills/foo";
let short_leaf = clone_dir_leaf("agents", Some(short_path), None);
assert!(
short_leaf.contains(short_path.replace('/', "%2F").as_str()),
"a short item_path must stay readably encoded: {short_leaf}"
);
let long_path = format!("skills/{}", "x".repeat(200));
let long_leaf = clone_dir_leaf("agents", Some(&long_path), None);
assert!(
long_leaf.len() <= 120,
"an over-length leaf must fall back to a short hash: {} bytes",
long_leaf.len()
);
assert!(
!long_leaf.contains("xxxx"),
"the fallback must not embed the readable path: {long_leaf}"
);
assert!(
long_leaf.starts_with("agents#"),
"the fallback must keep the repo#-prefix shape: {long_leaf}"
);
let long_leaf_again = clone_dir_leaf("agents", Some(&long_path), None);
assert_eq!(
long_leaf, long_leaf_again,
"the hash fallback must be stable for the same item_path"
);
let long_leaf_aliased = clone_dir_leaf("agents", Some(&long_path), Some("jk"));
assert!(
long_leaf_aliased.ends_with("@jk"),
"an alias must still append onto the hash fallback: {long_leaf_aliased}"
);
}
#[test]
fn clone_dir_leaf_length_threshold_is_exclusive_at_120_bytes() {
let at_limit = "x".repeat(118);
let leaf = clone_dir_leaf("r", Some(&at_limit), None);
assert_eq!(leaf.len(), 120);
assert_eq!(
leaf,
format!("r#{at_limit}"),
"a leaf of exactly 120 bytes must stay readable, not hash"
);
let over_limit = "x".repeat(119);
let leaf = clone_dir_leaf("r", Some(&over_limit), None);
assert_eq!(
leaf,
format!("r#{}", crate::hash::hash_str(&over_limit)),
"121 bytes exceeds the limit and must fall back to the hash"
);
}
#[test]
fn clone_dir_leaf_encoding_is_injective_for_the_percent_slash_collision_pair() {
let with_slash = clone_dir_leaf("r", Some("a/b"), None);
let with_literal_escape = clone_dir_leaf("r", Some("a%2Fb"), None);
assert_eq!(with_slash, "r#a%2Fb");
assert_eq!(with_literal_escape, "r#a%252Fb");
assert_ne!(
with_slash, with_literal_escape,
"'a/b' and 'a%2Fb' are distinct item paths and must not share a leaf"
);
}
#[test]
fn clone_dir_end_to_end_distinguishes_link_instances_and_plain_meld() {
let (base, paths) = tmp_paths_src();
let link_a = parse_spec("https://github.com/o/r/tree/main/skills/a").unwrap();
let link_b = parse_spec("https://github.com/o/r/tree/main/skills/b").unwrap();
let plain = parse_spec("https://github.com/o/r").unwrap();
let dir_a = link_a.clone_dir(&paths);
let dir_b = link_b.clone_dir(&paths);
let dir_plain = plain.clone_dir(&paths);
assert_ne!(
dir_a, dir_b,
"two link instances must not share a clone dir"
);
assert_ne!(
dir_a, dir_plain,
"a link instance must not share a clone dir with a plain meld"
);
assert_ne!(
dir_b, dir_plain,
"a link instance must not share a clone dir with a plain meld"
);
assert!(
dir_plain.ends_with("github.com/o/r"),
"plain meld unaffected: {dir_plain:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn legacy_display_alias_keeps_bare_identity_and_clone() {
let (base, paths) = tmp_paths_src();
std::fs::write(
base.join("sources.json"),
r#"{"version":1,"sources":[{"name":"github.com/acme/agents","url":"https://github.com/acme/agents","host":"github.com","owner":"acme","repo":"agents","alias":"jk"}]}"#,
)
.unwrap();
let bare = paths
.sources_dir()
.join("github.com")
.join("acme")
.join("agents");
std::fs::create_dir_all(&bare).unwrap();
std::fs::write(bare.join("marker"), "x").unwrap();
let reg = Registry::load(&paths).expect("load");
let s = ®.sources[0];
assert_eq!(s.name, "github.com/acme/agents", "identity stays bare");
assert_eq!(s.alias.as_deref(), Some("jk"), "display prefix preserved");
assert_eq!(s.as_alias, None, "no identity alias for a legacy source");
assert_eq!(
s.clone_dir(&paths),
bare,
"clone stays at the bare path (no relocation)"
);
assert!(bare.join("marker").exists());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn migrate_relative_local_url_rewrites_only_when_it_resolves() {
let (base, _paths) = tmp_paths_src();
let real_dir = base.join("real");
std::fs::create_dir_all(&real_dir).unwrap();
let mut resolves = parse_spec(&real_dir.to_string_lossy()).unwrap();
let name_before = resolves.name.clone();
resolves.url = "real".to_string();
let rewritten = migrate_relative_local_url(&base, &mut resolves);
assert!(rewritten, "a resolving relative url must be rewritten");
assert_eq!(resolves.url, real_dir.to_string_lossy());
assert_eq!(
resolves.name, name_before,
"the migration must never rewrite name, only url"
);
let mut ghost = parse_spec("/home/user/dev/agents").unwrap();
ghost.url = "does-not-exist".to_string();
let name_before = ghost.name.clone();
let rewritten = migrate_relative_local_url(&base, &mut ghost);
assert!(
!rewritten,
"a non-resolving relative url must not be rewritten"
);
assert_eq!(ghost.url, "does-not-exist");
assert_eq!(ghost.name, name_before, "name must never be rewritten");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn migrate_relative_local_url_is_a_noop_for_remote_and_absolute_sources() {
let (base, _paths) = tmp_paths_src();
let mut remote = parse_spec("acme/agents").unwrap();
let remote_url_before = remote.url.clone();
assert!(!migrate_relative_local_url(&base, &mut remote));
assert_eq!(remote.url, remote_url_before);
let mut abs = parse_spec("/already/absolute").unwrap();
let abs_url_before = abs.url.clone();
assert!(!migrate_relative_local_url(&base, &mut abs));
assert_eq!(abs.url, abs_url_before);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn migrate_relative_local_url_does_not_rewrite_when_the_target_is_a_file() {
let (base, _paths) = tmp_paths_src();
std::fs::write(base.join("not-a-dir"), "x").unwrap();
let mut src = parse_spec("/home/user/dev/agents").unwrap();
src.url = "not-a-dir".to_string();
assert!(
!migrate_relative_local_url(&base, &mut src),
"a relative url resolving to a FILE must not be migrated"
);
assert_eq!(src.url, "not-a-dir", "the recorded url must be left as-is");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn registry_load_migrates_a_resolving_relative_local_url() {
let cwd = std::env::current_dir().expect("must read cwd");
let unique = format!(
"mind-sto73-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let target_abs = cwd.parent().expect("cwd must have a parent").join(&unique);
std::fs::create_dir_all(&target_abs).unwrap();
let relative = format!("../{unique}");
let (base, paths) = tmp_paths_src();
let sources_json = format!(
r#"{{"version":1,"sources":[{{"name":"local/a/b","url":{:?},"host":"local","owner":"a","repo":"b"}}]}}"#,
relative
);
std::fs::write(base.join("sources.json"), sources_json).unwrap();
let reg = Registry::load(&paths).expect("load must succeed");
assert_eq!(reg.sources.len(), 1);
assert_eq!(
reg.sources[0].url,
target_abs.to_string_lossy(),
"a resolving relative url must be migrated to its absolute form"
);
assert_eq!(
reg.sources[0].name, "local/a/b",
"name (identity) must never be rewritten by the migration"
);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&target_abs);
}
#[test]
fn resolve_local_dir_finds_an_existing_relative_directory_against_base() {
let (base, _paths) = tmp_paths_src();
let nested = base.join("skills").join("greet");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(
resolve_local_dir(&base, "skills/greet"),
Some(nested.clone())
);
assert_eq!(
resolve_local_dir(&base, "skills/does-not-exist"),
None,
"a non-existent path must not be reported as a directory"
);
assert_eq!(
resolve_local_dir(&base, &nested.to_string_lossy()),
Some(nested.clone())
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn local_dir_shadow_is_true_only_when_resolve_local_dir_finds_something() {
let (base, _paths) = tmp_paths_src();
let nested = base.join("owner").join("repo");
std::fs::create_dir_all(&nested).unwrap();
assert!(local_dir_shadow(&base, "owner/repo"));
assert!(!local_dir_shadow(&base, "owner/nonexistent"));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn parse_spec_quiet_agrees_with_parse_spec_across_the_whole_grammar() {
let cwd = std::env::current_dir().expect("cwd");
assert!(
local_dir_shadow(&cwd, "src/tui"),
"fixture rotted: `src/tui` must be a directory relative to the test \
cwd ({cwd:?}) for the CLI-215 note branch to be exercised here"
);
let specs = [
"src/tui",
"owner/repo",
"github:owner/repo",
" owner/repo ",
"owner/repo.git",
"https://github.com/owner/repo",
"https://github.com/owner/repo.git",
"https://gitlab.com/owner/repo",
"http://example.com/owner/repo",
"ssh://git@github.com/owner/repo",
"ssh://git@github.com/owner/repo.git",
"git@github.com:owner/repo",
"git@github.com:owner/repo.git",
"/abs/path/repo",
"/abs/path/repo/",
"./rel/repo",
"../rel/repo",
"file:///abs/path/repo",
"https://github.com/owner/repo/tree/main/skills/greet",
"https://github.com/owner/repo/blob/abc1234/skills/greet/SKILL.md",
"https://gitlab.com/owner/repo/-/tree/main/skills/greet",
"file:///abs/path/repo/tree/main/skills/greet",
"",
" ",
"notaspec",
"owner/repo/extra",
"https://github.com/owner",
"https://github.com",
"git@github.com",
"https://user:pw@github.com/owner/repo",
"ssh://@github.com/owner/repo",
];
for spec in specs {
let loud = parse_spec(spec);
let quiet = parse_spec_quiet(spec);
match (&loud, &quiet) {
(Ok(a), Ok(b)) => assert_eq!(
format!("{a:?}"),
format!("{b:?}"),
"parse_spec and parse_spec_quiet must return the SAME Source \
for {spec:?}; the `note` flag may only change what is printed"
),
(Err(a), Err(b)) => assert_eq!(
format!("{a:?}"),
format!("{b:?}"),
"parse_spec and parse_spec_quiet must reject {spec:?} with the \
SAME error"
),
_ => panic!(
"parse_spec and parse_spec_quiet disagree on whether {spec:?} \
parses at all: loud={loud:?} quiet={quiet:?}"
),
}
}
}
#[test]
fn the_quiet_equivalence_table_reaches_every_parse_branch() {
let host_of = |spec: &str| {
parse_spec_quiet(spec)
.unwrap_or_else(|e| panic!("{spec:?} must parse: {e:?}"))
.host
};
assert_eq!(host_of("owner/repo"), "github.com");
assert_eq!(host_of("github:owner/repo"), "github.com");
assert_eq!(host_of("https://gitlab.com/o/r"), "gitlab.com");
assert_eq!(host_of("git@github.com:o/r"), "github.com");
assert_eq!(host_of("ssh://git@github.com/o/r"), "github.com");
assert_eq!(host_of("/abs/path/repo"), "local");
assert_eq!(host_of("file:///abs/path/repo"), "local");
assert!(
parse_spec_quiet("https://github.com/o/r/tree/main/skills/greet")
.expect("remote item link must parse")
.item_path
.is_some(),
"the remote item-link branch must be reachable from the table"
);
assert!(
parse_spec_quiet("file:///abs/path/repo/tree/main/skills/greet")
.expect("local item link must parse")
.item_path
.is_some(),
"the local item-link branch must be reachable from the table"
);
assert!(
parse_spec_quiet("notaspec").is_err(),
"the rejection branch must be reachable from the table"
);
}
}