pub mod sync;
use crate::config::Config;
use crate::config::types::{AssetKind, AssetRoot};
use crate::markdown::frontmatter::{self, Parsed};
use crate::workspace::owners::{PathMatch, matcher};
use ignore::WalkBuilder;
use std::collections::BTreeSet;
use std::path::Path;
fn join(dir: &str, rel: &str) -> String {
let mut parts: Vec<&str> = if dir.is_empty() { vec![] } else { dir.split('/').collect() };
for c in rel.split('/') {
match c {
"" | "." => {}
".." => {
if parts.pop().is_none() {
return rel.to_string();
}
}
c => parts.push(c),
}
}
parts.join("/")
}
fn shaped(kind: AssetKind, rel: &str) -> bool {
let file = rel.rsplit('/').next().unwrap_or(rel);
match kind {
AssetKind::Skill => file == "SKILL.md",
AssetKind::Rule => file.ends_with(".md") || file.ends_with(".mdc"),
AssetKind::Agent | AssetKind::Instructions => file.ends_with(".md"),
}
}
fn stem(kind: AssetKind, rel: &str) -> String {
let mut parts = rel.rsplit('/');
let file = parts.next().unwrap_or(rel);
match kind {
AssetKind::Skill => parts.next().unwrap_or("").to_string(),
_ => file.rsplit_once('.').map(|(s, _)| s).unwrap_or(file).to_string(),
}
}
pub fn template(
cfg: &Config,
kind: AssetKind,
name: &str,
description: &str,
title: &str,
) -> Option<(String, String, bool)> {
let declared = cfg
.raw
.assets
.iter()
.find(|r| r.kind == kind && base(r.path.trim_end_matches('/')) == r.path.trim_end_matches('/'));
let dir = declared.map(|r| r.path.trim_end_matches('/').to_string());
let one_line = description.replace('\n', " ");
let (path, text) = match kind {
AssetKind::Skill => (
format!("{}/{name}/SKILL.md", dir.as_deref().unwrap_or(".agents/skills")),
format!(
"---\nname: {name}\ndescription: {one_line}\n---\n\n# {title}\n\nWhen to use this skill and the steps to follow.\n"
),
),
AssetKind::Rule => (
format!("{}/{name}.mdc", dir.as_deref().unwrap_or(".cursor/rules")),
format!(
"---\ndescription: {one_line}\nglobs:\nalwaysApply: false\n---\n\n# {title}\n\nThe rule, with a short example.\n"
),
),
AssetKind::Agent => (
format!("{}/{name}.md", dir.as_deref().unwrap_or(".claude/agents")),
format!(
"---\nname: {name}\ndescription: {one_line}\n---\n\nYou are the {title} agent. What you do, and what you hand back.\n"
),
),
AssetKind::Instructions => return None,
};
let covered = declared.is_some()
|| cfg.raw.assets.iter().any(|r| r.kind == kind && matcher(&r.path).is_some_and(|m| m.is_match(&path)));
Some((path, text, covered))
}
fn base(path: &str) -> String {
let lit: Vec<&str> = path.split('/').take_while(|c| !c.contains(['*', '?', '[', '{'])).collect();
lit.join("/")
}
fn files(root: &Path, def: &AssetRoot) -> Vec<String> {
let Some(want) = matcher(&def.path) else { return vec![] };
let exclude: Vec<PathMatch> = def.exclude.iter().filter_map(|e| matcher(e)).collect();
let start = root.join(base(def.path.trim_end_matches('/')));
if !start.exists() {
return vec![];
}
let mut out = vec![];
for e in WalkBuilder::new(&start).hidden(false).build().flatten() {
if !e.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let Ok(rel) = e.path().strip_prefix(root) else { continue };
let rel = rel.to_string_lossy().replace('\\', "/");
if want.is_match(&rel) && !exclude.iter().any(|m| m.is_match(&rel)) && shaped(def.kind, &rel) {
out.push(rel);
}
}
out
}
fn read(root: &Path, kind: AssetKind, rel: String) -> Option<Asset> {
let text = std::fs::read_to_string(root.join(&rel)).ok()?;
let split = frontmatter::split(&text);
let body_line = split.body_line;
let (fields, invalid) = match frontmatter::parse(split.raw) {
Parsed::None => (None, None),
Parsed::Map(m) => (Some(m), None),
Parsed::Invalid(why) => (None, Some(why)),
};
Some(Asset { kind, stem: stem(kind, &rel), path: rel, fields, invalid, body_line, text })
}
pub fn load(cfg: &Config) -> Vec<Asset> {
let mut seen = BTreeSet::new();
let mut out = vec![];
for def in &cfg.raw.assets {
for rel in files(&cfg.root, def) {
if seen.insert(rel.clone())
&& let Some(a) = read(&cfg.root, def.kind, rel)
{
out.push(a);
}
}
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
mod asset;
#[cfg(test)]
mod tests;
pub use asset::Asset;