use std::collections::HashSet;
pub fn apply(bare: &str, prefix: &Option<String>) -> String {
match prefix {
Some(p) if !p.is_empty() => format!("{p}-{bare}"),
_ => bare.to_string(),
}
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '-' || c == '_'
}
pub fn templatize(content: &str, siblings: &HashSet<String>) -> (String, usize) {
let mut out = String::with_capacity(content.len());
let mut count = 0;
let mut in_fence = false;
let mut in_frontmatter = false;
for (idx, raw) in content.split_inclusive('\n').enumerate() {
let line = raw.strip_suffix('\n').unwrap_or(raw);
let nl = &raw[line.len()..];
let trimmed = line.trim();
if idx == 0 && trimmed == "---" {
in_frontmatter = true;
out.push_str(raw);
continue;
}
if in_frontmatter {
if trimmed == "---" {
in_frontmatter = false;
}
out.push_str(raw); continue;
}
if trimmed.starts_with("```") {
in_fence = !in_fence;
out.push_str(raw);
continue;
}
if in_fence {
out.push_str(raw); continue;
}
let (wrapped, n) = wrap_line(line, siblings);
out.push_str(&wrapped);
out.push_str(nl);
count += n;
}
(out, count)
}
fn wrap_line(line: &str, siblings: &HashSet<String>) -> (String, usize) {
let chars: Vec<char> = line.chars().collect();
let mut out = String::with_capacity(line.len());
let mut count = 0;
let mut word = String::new();
let mut in_span = false;
let mut before: Option<char> = None;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '{' && chars.get(i + 1) == Some(&'{') {
count += emit_word(&word, siblings, in_span, before, None, &mut out);
word.clear();
let start = i;
i += 2;
while i + 1 < chars.len() && !(chars[i] == '}' && chars[i + 1] == '}') {
i += 1;
}
i = if i + 1 < chars.len() {
i + 2
} else {
chars.len()
};
for &ch in &chars[start..i] {
out.push(ch);
}
before = Some('}');
continue;
}
if c == '`' {
count += emit_word(&word, siblings, in_span, before, Some('`'), &mut out);
word.clear();
in_span = !in_span;
out.push(c);
before = Some('`');
i += 1;
continue;
}
if is_word_char(c) {
word.push(c);
i += 1;
continue;
}
count += emit_word(&word, siblings, in_span, before, Some(c), &mut out);
word.clear();
out.push(c);
before = Some(c);
i += 1;
}
count += emit_word(&word, siblings, in_span, before, None, &mut out);
(out, count)
}
fn emit_word(
word: &str,
siblings: &HashSet<String>,
in_span: bool,
before: Option<char>,
after: Option<char>,
out: &mut String,
) -> usize {
if word.is_empty() {
return 0;
}
let path_adj = matches!(before, Some('/') | Some('~')) || matches!(after, Some('/'));
if !in_span && !path_adj && siblings.contains(word) {
out.push_str("{{ns:");
out.push_str(word);
out.push_str("}}");
1
} else {
out.push_str(word);
0
}
}
pub fn prefix_choice(answer: &str) -> Option<String> {
let a = answer.trim();
match a.to_ascii_lowercase().as_str() {
"" | "y" | "yes" => None,
"n" | "no" | "none" => Some(String::new()),
_ => Some(a.to_string()),
}
}
pub fn expand(
content: &str,
prefix: &Option<String>,
siblings: &HashSet<String>,
) -> Result<String, String> {
const OPEN: &str = "{{ns:";
let mut out = String::with_capacity(content.len());
let mut rest = content;
while let Some(pos) = rest.find(OPEN) {
out.push_str(&rest[..pos]);
let after = &rest[pos + OPEN.len()..];
let Some(end) = after.find("}}") else {
out.push_str(&rest[pos..]);
return Ok(out);
};
let name = after[..end].trim();
if !siblings.contains(name) {
return Err(name.to_string());
}
out.push_str(&apply(name, prefix));
rest = &after[end + 2..];
}
out.push_str(rest);
Ok(out)
}
#[derive(Debug, Clone)]
pub struct PathSibling {
pub kind: crate::error::ItemKind,
pub name: String,
pub bin: Option<String>,
}
pub struct PathCtx<'a> {
pub store_root: &'a std::path::Path,
pub home: Option<&'a std::path::Path>,
pub prefix: &'a Option<String>,
pub self_kind: crate::error::ItemKind,
pub self_name: &'a str,
pub siblings: &'a [PathSibling],
}
impl PathCtx<'_> {
fn store_path(&self, kind: crate::error::ItemKind, bare: &str) -> String {
let abs = self
.store_root
.join(kind.as_str())
.join(apply(bare, self.prefix));
render_under_home(&abs, self.home)
}
}
fn render_under_home(path: &std::path::Path, home: Option<&std::path::Path>) -> String {
if let Some(home) = home
&& let Ok(rest) = path.strip_prefix(home)
{
return std::path::Path::new("~")
.join(rest)
.to_string_lossy()
.into_owned();
}
path.to_string_lossy().into_owned()
}
enum Token {
Path(String),
Passthrough,
Bad,
}
pub fn expand_paths(content: &str, ctx: &PathCtx) -> Result<String, String> {
let mut out = String::with_capacity(content.len());
let mut rest = content;
while let Some(pos) = rest.find("{{") {
out.push_str(&rest[..pos]);
let after = &rest[pos + 2..];
let Some(end) = after.find("}}") else {
out.push_str(&rest[pos..]);
return Ok(out);
};
let inner = after[..end].trim();
match resolve_token(inner, ctx) {
Token::Path(p) => {
out.push_str(&p);
rest = &after[end + 2..];
}
Token::Bad => {
return Err(rest[pos..pos + 2 + end + 2].to_string());
}
Token::Passthrough => {
out.push_str("{{");
rest = after;
}
}
}
out.push_str(rest);
Ok(out)
}
fn resolve_token(inner: &str, ctx: &PathCtx) -> Token {
if inner == "self" {
return Token::Path(ctx.store_path(ctx.self_kind, ctx.self_name));
}
if let Some(name) = inner.strip_prefix("tools:") {
let name = name.trim();
return match ctx
.siblings
.iter()
.find(|s| s.kind == crate::error::ItemKind::Tool && s.name == name)
{
Some(tool) => match &tool.bin {
Some(bin) => Token::Path(
std::path::Path::new(&ctx.store_path(crate::error::ItemKind::Tool, name))
.join(bin)
.to_string_lossy()
.into_owned(),
),
None => Token::Bad,
},
None => Token::Bad,
};
}
if let Some(reference) = inner.strip_prefix("path:") {
let reference = reference.trim();
let (want_kind, name) = match reference.split_once(':') {
Some((k, n)) => match crate::error::ItemKind::parse(k) {
Some(kind) => (Some(kind), n.trim()),
None => return Token::Bad,
},
None => (None, reference),
};
let mut hits = ctx
.siblings
.iter()
.filter(|s| s.name == name && want_kind.is_none_or(|k| s.kind == k));
return match (hits.next(), hits.next()) {
(Some(s), None) => Token::Path(ctx.store_path(s.kind, name)),
_ => Token::Bad,
};
}
Token::Passthrough
}
const HOME_MARKERS: [&str; 5] = ["~/", "$HOME/", "${HOME}/", "/home/", "/Users/"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HardcodedKind {
OwnResource,
SharedTool,
OtherItem,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HardcodedPath {
pub matched: String,
pub suggestion: Option<String>,
pub kind: HardcodedKind,
}
fn canonical_install_path(path: &str) -> Option<String> {
let rest = if let Some(r) = path.strip_prefix("~/") {
r
} else if let Some(r) = path.strip_prefix("$HOME/") {
r
} else if let Some(r) = path.strip_prefix("${HOME}/") {
r
} else if let Some(r) = path
.strip_prefix("/home/")
.or_else(|| path.strip_prefix("/Users/"))
{
r.split_once('/').map(|(_user, rest)| rest)?
} else {
return None;
};
if rest.starts_with(".mind/store/")
|| rest.starts_with(".claude/")
|| rest.starts_with(".agents/")
{
Some(format!("~/{rest}"))
} else {
None
}
}
fn is_path_terminator(c: char) -> bool {
c.is_whitespace()
|| matches!(
c,
'"' | '\'' | '`' | ')' | ']' | '}' | ',' | ';' | '<' | '>'
)
}
fn parse_install_path(path: &str) -> Option<(crate::error::ItemKind, String, String)> {
let after_kind = if let Some(rest) = path.strip_prefix("~/.mind/store/") {
let mut it = rest.splitn(2, '/');
let kind = crate::error::ItemKind::parse(it.next()?)?;
(kind, it.next()?.to_string())
} else if let Some(rest) = path
.strip_prefix("~/.claude/")
.or_else(|| path.strip_prefix("~/.agents/"))
{
let mut it = rest.splitn(2, '/');
let kind = crate::error::ItemKind::from_dir(it.next()?)?;
(kind, it.next()?.to_string())
} else {
return None;
};
let (kind, tail) = after_kind;
let mut seg = tail.splitn(2, '/');
let first = seg.next()?;
let rest = seg.next().unwrap_or("").to_string();
let name = match kind {
crate::error::ItemKind::Agent | crate::error::ItemKind::Rule => {
first.strip_suffix(".md").unwrap_or(first).to_string()
}
_ => first.to_string(),
};
if name.is_empty() {
return None;
}
Some((kind, name, rest))
}
fn join_token(token: &str, rest: &str) -> String {
if rest.is_empty() {
token.to_string()
} else {
format!("{token}/{rest}")
}
}
fn token_for_path(path: &str, ctx: &PathCtx) -> Option<String> {
let (kind, name, rest) = parse_install_path(path)?;
if kind == ctx.self_kind && name == ctx.self_name {
return Some(join_token("{{self}}", &rest));
}
let sib = ctx
.siblings
.iter()
.find(|s| s.kind == kind && s.name == name)?;
if kind == crate::error::ItemKind::Tool {
if let Some(bin) = &sib.bin
&& rest == *bin
{
return Some(format!("{{{{tools:{name}}}}}"));
}
return Some(join_token(&format!("{{{{path:tool:{name}}}}}"), &rest));
}
Some(join_token(
&format!("{{{{path:{}:{}}}}}", kind.as_str(), name),
&rest,
))
}
fn classify_path(canonical: &str, ctx: &PathCtx) -> (HardcodedKind, Option<String>) {
let suggestion = token_for_path(canonical, ctx);
let kind = match parse_install_path(canonical) {
Some((k, name, _)) => {
if k == ctx.self_kind && name == ctx.self_name {
HardcodedKind::OwnResource
} else if k == crate::error::ItemKind::Tool
&& ctx.siblings.iter().any(|s| s.kind == k && s.name == name)
{
HardcodedKind::SharedTool
} else {
HardcodedKind::OtherItem
}
}
None => HardcodedKind::OtherItem,
};
(kind, suggestion)
}
fn scan_hardcoded(content: &str, ctx: &PathCtx) -> Vec<(usize, usize, HardcodedPath)> {
let mut out = Vec::new();
let mut i = 0;
while i < content.len() {
let Some((start, marker)) = HOME_MARKERS
.iter()
.filter_map(|m| content[i..].find(m).map(|off| (i + off, *m)))
.min_by_key(|(pos, _)| *pos)
else {
break;
};
let scan_from = start + marker.len();
let mut end = content.len();
for (idx, c) in content[scan_from..].char_indices() {
if is_path_terminator(c) {
end = scan_from + idx;
break;
}
}
let matched = content[start..end].to_string();
if let Some(canonical) = canonical_install_path(&matched) {
let (kind, suggestion) = classify_path(&canonical, ctx);
out.push((
start,
end,
HardcodedPath {
matched,
suggestion,
kind,
},
));
}
i = end.max(start + 1);
}
out
}
pub fn detect_hardcoded_paths(content: &str, ctx: &PathCtx) -> Vec<HardcodedPath> {
scan_hardcoded(content, ctx)
.into_iter()
.map(|(_, _, hp)| hp)
.collect()
}
pub fn rewrite_hardcoded_paths(content: &str, ctx: &PathCtx) -> (String, usize) {
let mut out = String::with_capacity(content.len());
let mut last = 0;
let mut count = 0;
for (start, end, hp) in scan_hardcoded(content, ctx) {
if let Some(token) = hp.suggestion {
out.push_str(&content[last..start]);
out.push_str(&token);
last = end;
count += 1;
}
}
out.push_str(&content[last..]);
(out, count)
}
fn strip_braced(content: &str) -> String {
let mut out = String::with_capacity(content.len());
let mut rest = content;
while let Some(pos) = rest.find("{{") {
out.push_str(&rest[..pos]);
let after = &rest[pos + 2..];
match after.find("}}") {
Some(end) => {
out.push(' ');
rest = &after[end + 2..];
}
None => {
rest = "";
break;
}
}
}
out.push_str(rest);
out
}
pub fn bare_tool_refs(content: &str, siblings: &[PathSibling]) -> Vec<String> {
let stripped = strip_braced(content);
let mut found: Vec<String> = siblings
.iter()
.filter(|s| s.kind == crate::error::ItemKind::Tool)
.map(|s| s.name.clone())
.filter(|name| whole_word_present(&stripped, name))
.collect();
found.sort();
found.dedup();
found
}
pub fn referenced_names(content: &str) -> Vec<String> {
const OPEN: &str = "{{ns:";
let mut names: Vec<String> = Vec::new();
let mut rest = content;
while let Some(pos) = rest.find(OPEN) {
let after = &rest[pos + OPEN.len()..];
let Some(end) = after.find("}}") else {
break;
};
let name = after[..end].trim();
if !name.is_empty() && !names.iter().any(|n| n == name) {
names.push(name.to_string());
}
rest = &after[end + 2..];
}
names
}
pub fn unguarded_refs(content: &str, siblings: &HashSet<String>) -> Vec<String> {
let stripped = strip_tokens(content);
let mut found: Vec<String> = siblings
.iter()
.filter(|name| whole_word_present(&stripped, name))
.cloned()
.collect();
found.sort();
found
}
fn strip_tokens(content: &str) -> String {
const OPEN: &str = "{{ns:";
let mut out = String::with_capacity(content.len());
let mut rest = content;
while let Some(pos) = rest.find(OPEN) {
out.push_str(&rest[..pos]);
let after = &rest[pos + OPEN.len()..];
match after.find("}}") {
Some(end) => {
out.push(' ');
rest = &after[end + 2..];
}
None => {
rest = "";
break;
}
}
}
out.push_str(rest);
out
}
fn whole_word_present(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return false;
}
let mut start = 0;
while let Some(i) = haystack[start..].find(needle) {
let idx = start + i;
let before = haystack[..idx].chars().next_back();
let after = haystack[idx + needle.len()..].chars().next();
if !before.is_some_and(is_word) && !after.is_some_and(is_word) {
return true;
}
start = idx + needle.len();
}
false
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '-'
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NsContext {
Prose,
CodeBlock,
CodeSpan,
Path,
FrontmatterName,
}
impl NsContext {
pub fn is_misplaced(self) -> bool {
!matches!(self, NsContext::Prose)
}
}
#[derive(Debug, Clone)]
pub struct NsRef {
pub name: String,
pub context: NsContext,
pub start: usize,
pub end: usize,
}
fn in_code_span(line: &str, pos: usize) -> bool {
line[..pos].bytes().filter(|&b| b == b'`').count() % 2 == 1
}
fn path_adjacent(line: &str, start: usize, end: usize) -> bool {
let before = line[..start].chars().next_back();
let after = line[end..].chars().next();
matches!(before, Some('/') | Some('~')) || matches!(after, Some('/'))
}
pub fn scan_ns_refs(content: &str) -> Vec<NsRef> {
const OPEN: &str = "{{ns:";
let mut out = Vec::new();
let mut in_fence = false;
let mut in_frontmatter = false;
let mut offset = 0usize;
for (idx, raw) in content.split_inclusive('\n').enumerate() {
let line = raw.strip_suffix('\n').unwrap_or(raw);
let trimmed = line.trim();
if idx == 0 && trimmed == "---" {
in_frontmatter = true;
offset += raw.len();
continue;
}
if in_frontmatter {
if trimmed == "---" {
in_frontmatter = false;
offset += raw.len();
continue;
}
} else if trimmed.starts_with("```") {
in_fence = !in_fence;
offset += raw.len();
continue;
}
let fm_name = in_frontmatter && line.trim_start().starts_with("name:");
let mut from = 0;
while let Some(rel) = line[from..].find(OPEN) {
let tstart = from + rel;
let after = &line[tstart + OPEN.len()..];
let Some(erel) = after.find("}}") else { break };
let tend = tstart + OPEN.len() + erel + 2;
let name = after[..erel].trim().to_string();
let context = if fm_name {
NsContext::FrontmatterName
} else if in_frontmatter {
NsContext::Prose
} else if in_fence {
NsContext::CodeBlock
} else if in_code_span(line, tstart) {
NsContext::CodeSpan
} else if path_adjacent(line, tstart, tend) {
NsContext::Path
} else {
NsContext::Prose
};
if !name.is_empty() {
out.push(NsRef {
name,
context,
start: offset + tstart,
end: offset + tend,
});
}
from = tend;
}
offset += raw.len();
}
out
}
pub fn unwrap_misplaced(content: &str, all_code: bool) -> (String, usize) {
let mut out = String::with_capacity(content.len());
let mut last = 0;
let mut count = 0;
for r in scan_ns_refs(content) {
if all_code || r.context.is_misplaced() {
out.push_str(&content[last..r.start]);
out.push_str(&r.name);
last = r.end;
count += 1;
}
}
out.push_str(&content[last..]);
(out, count)
}
#[cfg(test)]
mod tests {
use super::*;
fn sibs(names: &[&str]) -> HashSet<String> {
names.iter().map(|s| s.to_string()).collect()
}
#[test]
fn apply_prefixes_or_passes_through() {
assert_eq!(apply("review", &Some("jk".into())), "jk-review");
assert_eq!(apply("review", &None), "review");
assert_eq!(apply("review", &Some(String::new())), "review");
}
#[test]
fn templatize_wraps_bare_siblings_and_skips_tokens() {
let s = sibs(&["dev", "style"]);
let (out, n) = templatize("hand off to dev, see {{ns:style}}, not develop", &s);
assert_eq!(
out, "hand off to {{ns:dev}}, see {{ns:style}}, not develop",
"bare `dev` is wrapped; the token and the longer word `develop` are left alone"
);
assert_eq!(n, 1, "only the one bare sibling mention is rewritten");
let (again, m) = templatize(&out, &s);
assert_eq!(again, out);
assert_eq!(m, 0);
}
#[test]
fn prefix_choice_interprets_the_meld_prompt() {
assert_eq!(prefix_choice(""), None);
assert_eq!(prefix_choice("y"), None);
assert_eq!(prefix_choice("YES"), None);
assert_eq!(prefix_choice("n"), Some(String::new()));
assert_eq!(prefix_choice("none"), Some(String::new()));
assert_eq!(prefix_choice(" MyPfx "), Some("MyPfx".to_string()));
}
#[test]
fn expand_unprefixed_yields_bare_names() {
let s = sibs(&["test"]);
let got = expand("hand off to {{ns:test}} now", &None, &s).unwrap();
assert_eq!(got, "hand off to test now");
}
#[test]
fn expand_prefixed_yields_prefixed_names() {
let s = sibs(&["test"]);
let got = expand("see {{ns:test}}.", &Some("jk".into()), &s).unwrap();
assert_eq!(got, "see jk-test.");
}
#[test]
fn expand_rejects_unknown_referent() {
let s = sibs(&["test"]);
assert_eq!(expand("{{ns:nope}}", &None, &s), Err("nope".to_string()));
}
#[test]
fn expand_passes_content_without_tokens() {
let s = sibs(&["test"]);
assert_eq!(
expand("no tokens here", &None, &s).unwrap(),
"no tokens here"
);
}
#[test]
fn expand_trims_token_and_leaves_unterminated_verbatim() {
let s = sibs(&["dev"]);
assert_eq!(
expand("{{ns: dev }}", &Some("jk".into()), &s).unwrap(),
"jk-dev"
);
assert_eq!(expand("see {{ns:dev", &None, &s).unwrap(), "see {{ns:dev");
}
#[test]
fn unguarded_finds_bare_prose_refs_only() {
let s = sibs(&["test", "planner"]);
let refs = unguarded_refs("run the test, then {{ns:planner}}", &s);
assert_eq!(refs, vec!["test".to_string()]);
}
#[test]
fn unguarded_respects_word_boundaries() {
let s = sibs(&["do"]);
assert!(unguarded_refs("doing work", &s).is_empty());
assert_eq!(unguarded_refs("just do it", &s), vec!["do".to_string()]);
}
#[test]
fn referenced_names_extracts_tokens_in_order_deduped() {
let got = referenced_names("see {{ns:test}} then {{ns:do}} then {{ns:test}}");
assert_eq!(got, vec!["test".to_string(), "do".to_string()]);
}
#[test]
fn referenced_names_trims_whitespace_inside_token() {
let got = referenced_names("{{ns: dev }}");
assert_eq!(got, vec!["dev".to_string()]);
}
#[test]
fn referenced_names_no_tokens_is_empty() {
assert!(referenced_names("plain prose, no tokens").is_empty());
}
#[test]
fn referenced_names_unterminated_token_is_not_a_reference() {
assert!(referenced_names("see {{ns:dev").is_empty());
assert_eq!(
referenced_names("{{ns:test}} then {{ns:dev"),
vec!["test".to_string()]
);
}
#[test]
fn referenced_names_empty_token_is_skipped() {
assert!(referenced_names("{{ns:}}").is_empty());
assert!(referenced_names("{{ns: }}").is_empty());
assert_eq!(
referenced_names("{{ns:}} then {{ns:dev}}"),
vec!["dev".to_string()]
);
}
#[test]
fn referenced_names_valid_then_unterminated_returns_valid_only() {
assert_eq!(
referenced_names("use {{ns:dev}} and then {{ns:planner"),
vec!["dev".to_string()]
);
}
#[test]
fn referenced_names_whitespace_or_empty_content_is_empty() {
assert!(referenced_names("").is_empty());
assert!(referenced_names(" \n\t ").is_empty());
}
#[test]
fn referenced_names_empty_token_does_not_swallow_following_close() {
assert_eq!(
referenced_names("{{ns:}}{{ns:dev}}"),
vec!["dev".to_string()]
);
}
use crate::error::ItemKind;
use std::path::Path;
fn psib(kind: ItemKind, name: &str, bin: Option<&str>) -> PathSibling {
PathSibling {
kind,
name: name.to_string(),
bin: bin.map(|s| s.to_string()),
}
}
fn ctx<'a>(
store: &'a Path,
prefix: &'a Option<String>,
self_kind: ItemKind,
self_name: &'a str,
siblings: &'a [PathSibling],
) -> PathCtx<'a> {
PathCtx {
store_root: store,
home: None,
prefix,
self_kind,
self_name,
siblings,
}
}
#[test]
fn self_token_resolves_to_own_store_dir() {
let store = Path::new("/m/store");
let none = None;
let c = ctx(store, &none, ItemKind::Skill, "review", &[]);
assert_eq!(
expand_paths("run {{self}}/resources/pr.py here", &c).unwrap(),
"run /m/store/skill/review/resources/pr.py here"
);
}
#[test]
fn self_token_is_prefix_aware() {
let store = Path::new("/m/store");
let pfx = Some("jk".to_string());
let c = ctx(store, &pfx, ItemKind::Skill, "review", &[]);
assert_eq!(
expand_paths("{{self}}", &c).unwrap(),
"/m/store/skill/jk-review"
);
}
#[test]
fn store_paths_render_with_tilde_when_under_home() {
let home = Path::new("/home/jk");
let store = Path::new("/home/jk/.mind/store");
let none = None;
let sibs = vec![psib(ItemKind::Tool, "shard-plan", Some("shard-plan"))];
let c = PathCtx {
store_root: store,
home: Some(home),
prefix: &none,
self_kind: ItemKind::Skill,
self_name: "review",
siblings: &sibs,
};
assert_eq!(
expand_paths("{{self}}/resources/pr.py", &c).unwrap(),
"~/.mind/store/skill/review/resources/pr.py"
);
assert_eq!(
expand_paths("{{tools:shard-plan}}", &c).unwrap(),
"~/.mind/store/tool/shard-plan/shard-plan"
);
assert_eq!(
expand_paths("{{path:tool:shard-plan}}/lib.sh", &c).unwrap(),
"~/.mind/store/tool/shard-plan/lib.sh"
);
}
#[test]
fn store_paths_stay_absolute_when_store_not_under_home() {
let home = Path::new("/home/jk");
let store = Path::new("/srv/mind/store");
let none = None;
let c = PathCtx {
store_root: store,
home: Some(home),
prefix: &none,
self_kind: ItemKind::Skill,
self_name: "review",
siblings: &[],
};
assert_eq!(
expand_paths("{{self}}", &c).unwrap(),
"/srv/mind/store/skill/review"
);
let c = PathCtx { home: None, ..c };
assert_eq!(
expand_paths("{{self}}", &c).unwrap(),
"/srv/mind/store/skill/review"
);
}
#[test]
fn tools_token_resolves_to_entrypoint() {
let store = Path::new("/m/store");
let none = None;
let sibs = vec![psib(ItemKind::Tool, "shard-plan", Some("shard-plan"))];
let c = ctx(store, &none, ItemKind::Skill, "review", &sibs);
assert_eq!(
expand_paths("pipe to {{tools:shard-plan}} --max 5", &c).unwrap(),
"pipe to /m/store/tool/shard-plan/shard-plan --max 5"
);
}
#[test]
fn tools_token_is_prefix_aware() {
let store = Path::new("/m/store");
let pfx = Some("jk".to_string());
let sibs = vec![psib(ItemKind::Tool, "shard-plan", Some("shard-plan"))];
let c = ctx(store, &pfx, ItemKind::Skill, "review", &sibs);
assert_eq!(
expand_paths("{{tools:shard-plan}}", &c).unwrap(),
"/m/store/tool/jk-shard-plan/shard-plan"
);
}
#[test]
fn tools_token_errors_on_missing_or_binless_or_non_tool() {
let store = Path::new("/m/store");
let none = None;
let c = ctx(store, &none, ItemKind::Skill, "review", &[]);
assert_eq!(
expand_paths("{{tools:nope}}", &c),
Err("{{tools:nope}}".to_string())
);
let binless = vec![psib(ItemKind::Tool, "x", None)];
let c = ctx(store, &none, ItemKind::Skill, "review", &binless);
assert_eq!(
expand_paths("{{tools:x}}", &c),
Err("{{tools:x}}".to_string())
);
let not_tool = vec![psib(ItemKind::Skill, "x", None)];
let c = ctx(store, &none, ItemKind::Skill, "review", ¬_tool);
assert_eq!(
expand_paths("{{tools:x}}", &c),
Err("{{tools:x}}".to_string())
);
}
#[test]
fn path_token_resolves_sibling_dir_qualified_and_bare() {
let store = Path::new("/m/store");
let none = None;
let sibs = vec![psib(ItemKind::Tool, "detect", Some("detect"))];
let c = ctx(store, &none, ItemKind::Skill, "review", &sibs);
assert_eq!(
expand_paths("{{path:tool:detect}}/lib/helper.sh", &c).unwrap(),
"/m/store/tool/detect/lib/helper.sh"
);
assert_eq!(
expand_paths("{{path:detect}}", &c).unwrap(),
"/m/store/tool/detect"
);
}
#[test]
fn path_token_ambiguity_errors_unless_kind_qualified() {
let store = Path::new("/m/store");
let none = None;
let sibs = vec![
psib(ItemKind::Skill, "x", None),
psib(ItemKind::Agent, "x", None),
];
let c = ctx(store, &none, ItemKind::Skill, "self", &sibs);
assert_eq!(
expand_paths("{{path:x}}", &c),
Err("{{path:x}}".to_string())
);
assert_eq!(
expand_paths("{{path:agent:x}}", &c).unwrap(),
"/m/store/agent/x"
);
assert_eq!(
expand_paths("{{path:none}}", &c),
Err("{{path:none}}".to_string())
);
}
#[test]
fn rewrite_maps_hardcoded_paths_to_tokens() {
let store = Path::new("/m/store");
let none = None;
let sibs = vec![
psib(ItemKind::Tool, "detect", Some("detect")),
psib(ItemKind::Skill, "release", None),
];
let c = ctx(store, &none, ItemKind::Skill, "review", &sibs);
let input = "self ~/.claude/skills/review/resources/pr.py \
tool ~/.mind/store/tool/detect/detect \
other ~/.mind/store/skill/release/x.sh \
foreign ~/.claude/skills/unknown/y.sh";
let (out, n) = rewrite_hardcoded_paths(input, &c);
assert_eq!(n, 3, "three confident rewrites: {out}");
assert!(out.contains("self {{self}}/resources/pr.py"), "{out}");
assert!(out.contains("tool {{tools:detect}}"), "{out}");
assert!(out.contains("other {{path:skill:release}}/x.sh"), "{out}");
assert!(
out.contains("foreign ~/.claude/skills/unknown/y.sh"),
"{out}"
);
}
#[test]
fn detect_reports_paths_with_and_without_suggestions() {
let store = Path::new("/m/store");
let none = None;
let sibs = vec![psib(ItemKind::Tool, "detect", Some("detect"))];
let c = ctx(store, &none, ItemKind::Skill, "review", &sibs);
let found = detect_hardcoded_paths(
"a ~/.mind/store/tool/detect/detect b ~/.agents/resources/x.sh",
&c,
);
assert_eq!(found.len(), 2);
assert_eq!(found[0].suggestion.as_deref(), Some("{{tools:detect}}"));
assert_eq!(found[1].suggestion, None);
}
#[test]
fn hardcoded_detects_env_and_absolute_home_forms() {
let store = Path::new("/m/store");
let none = None;
let sibs = vec![psib(ItemKind::Tool, "detect", Some("detect"))];
let c = ctx(store, &none, ItemKind::Skill, "review", &sibs);
for path in [
"$HOME/.mind/store/tool/detect/detect",
"${HOME}/.mind/store/tool/detect/detect",
"/home/jk/.mind/store/tool/detect/detect",
"/Users/jk/.mind/store/tool/detect/detect",
] {
let found = detect_hardcoded_paths(&format!("run {path} now"), &c);
assert_eq!(found.len(), 1, "{path}");
assert_eq!(found[0].matched, path, "matched span is the original form");
assert_eq!(
found[0].suggestion.as_deref(),
Some("{{tools:detect}}"),
"{path}"
);
}
assert!(detect_hardcoded_paths("see /home/jk/projects/x", &c).is_empty());
}
#[test]
fn hardcoded_classifies_own_tool_and_other() {
let store = Path::new("/m/store");
let none = None;
let sibs = vec![
psib(ItemKind::Tool, "detect", Some("detect")),
psib(ItemKind::Skill, "release", None),
];
let c = ctx(store, &none, ItemKind::Skill, "review", &sibs);
let found = detect_hardcoded_paths(
"own ~/.claude/skills/review/resources/pr.py \
tool ~/.mind/store/tool/detect/detect \
other ~/.mind/store/skill/release/x.sh \
foreign ~/.claude/skills/unknown/y.sh",
&c,
);
assert_eq!(found.len(), 4);
assert_eq!(found[0].kind, HardcodedKind::OwnResource);
assert_eq!(found[1].kind, HardcodedKind::SharedTool);
assert_eq!(found[2].kind, HardcodedKind::OtherItem);
assert_eq!(found[3].kind, HardcodedKind::OtherItem);
assert_eq!(found[3].suggestion, None);
}
#[test]
fn bare_tool_refs_finds_tool_names_outside_tokens() {
let sibs = vec![
psib(ItemKind::Tool, "detect", Some("detect")),
psib(ItemKind::Skill, "review", None),
];
let refs = bare_tool_refs("run detect then review; later {{tools:detect}}", &sibs);
assert_eq!(refs, vec!["detect".to_string()]);
assert!(bare_tool_refs("just detect", &sibs).contains(&"detect".to_string()));
}
#[test]
fn path_tokens_ignore_ns_and_handle_edges() {
let store = Path::new("/m/store");
let none = None;
let c = ctx(store, &none, ItemKind::Tool, "t", &[]);
assert_eq!(
expand_paths("{{ns:foo}} then {{self}}", &c).unwrap(),
"{{ns:foo}} then /m/store/tool/t"
);
assert_eq!(expand_paths("{{ self }}", &c).unwrap(), "/m/store/tool/t");
assert_eq!(
expand_paths("see {{self", &c).unwrap(),
"see {{self".to_string()
);
assert_eq!(expand_paths("plain prose", &c).unwrap(), "plain prose");
assert_eq!(expand_paths("a {{x}} b", &c).unwrap(), "a {{x}} b");
}
#[test]
fn scan_ns_refs_classifies_context() {
let doc = "---\nname: {{ns:dev}}\ndescription: see {{ns:review}}\n---\n\
prose {{ns:dev}} here\n`{{ns:test}}` span\n~/{{ns:dev}}\n\
```\n{{ns:do}}\n```\n";
let got: Vec<(String, NsContext)> = scan_ns_refs(doc)
.into_iter()
.map(|r| (r.name, r.context))
.collect();
assert_eq!(
got,
vec![
("dev".into(), NsContext::FrontmatterName),
("review".into(), NsContext::Prose), ("dev".into(), NsContext::Prose),
("test".into(), NsContext::CodeSpan),
("dev".into(), NsContext::Path),
("do".into(), NsContext::CodeBlock),
]
);
}
#[test]
fn templatize_skips_code_paths_and_frontmatter() {
let s = sibs(&["dev", "do"]);
let doc = "---\nname: dev\n---\nuse dev here\n`dev`\n~/dev\n```\nfor x; do\n```\n";
let (out, n) = templatize(doc, &s);
assert_eq!(n, 1, "only the prose mention is wrapped: {out}");
assert!(out.contains("use {{ns:dev}} here"), "{out}");
assert!(out.contains("`dev`"), "code span untouched: {out}");
assert!(out.contains("~/dev"), "path untouched: {out}");
assert!(out.contains("for x; do"), "code block untouched: {out}");
assert!(out.contains("name: dev"), "frontmatter untouched: {out}");
}
#[test]
fn unwrap_misplaced_restores_words() {
let doc = "prose {{ns:dev}}\n`{{ns:test}}`\n~/{{ns:dev}}\n";
let (out, n) = unwrap_misplaced(doc, false);
assert_eq!(
n, 2,
"code-span and path tokens un-wrapped, prose kept: {out}"
);
assert_eq!(out, "prose {{ns:dev}}\n`test`\n~/dev\n");
let (all, m) = unwrap_misplaced(doc, true);
assert_eq!(m, 3);
assert_eq!(all, "prose dev\n`test`\n~/dev\n");
}
}