use crate::core::ResourceType;
use crate::manifest::{Manifest, ResourceDependency};
use crate::utils::{compute_relative_install_path, normalize_path_for_storage};
use anyhow::Result;
use std::path::{Path, PathBuf};
pub fn parse_pattern_base_path(pattern: &str) -> (PathBuf, String) {
if pattern.contains('/') || pattern.contains('\\') {
let pattern_path = Path::new(pattern);
if let Some(parent) = pattern_path.parent() {
if parent.is_absolute() || parent.starts_with("..") || parent.starts_with(".") {
(
parent.to_path_buf(),
pattern_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(pattern)
.to_string(),
)
} else {
(PathBuf::from("."), pattern.to_string())
}
} else {
(PathBuf::from("."), pattern.to_string())
}
} else {
(PathBuf::from("."), pattern.to_string())
}
}
pub fn compute_merge_target_install_path(
manifest: &Manifest,
artifact_type: &str,
resource_type: ResourceType,
) -> String {
if let Some(merge_target) = manifest.get_merge_target(artifact_type, resource_type) {
normalize_path_for_storage(merge_target.display().to_string())
} else {
match resource_type {
ResourceType::Hook => ".claude/settings.local.json".to_string(),
ResourceType::McpServer => {
if artifact_type == "opencode" {
".opencode/opencode.json".to_string()
} else {
".mcp.json".to_string()
}
}
_ => unreachable!(
"compute_merge_target_install_path should only be called for Hook or McpServer"
),
}
}
}
pub fn compute_regular_resource_install_path(
manifest: &Manifest,
dep: &ResourceDependency,
artifact_type: &str,
resource_type: ResourceType,
filename: &str,
) -> Result<String> {
let artifact_path =
manifest.get_artifact_resource_path(artifact_type, resource_type).ok_or_else(|| {
anyhow::anyhow!(
"Resource type '{}' is not supported by tool '{}'",
resource_type,
artifact_type
)
})?;
let flatten = get_flatten_behavior(manifest, dep, artifact_type, resource_type);
let base_target = if let Some(custom_target) = dep.get_target() {
PathBuf::from(artifact_path.display().to_string())
.join(custom_target.trim_start_matches(['/', '\\']))
} else {
artifact_path.to_path_buf()
};
let relative_path = compute_relative_install_path(&base_target, Path::new(filename), flatten);
Ok(normalize_path_for_storage(base_target.join(relative_path)))
}
pub fn get_flatten_behavior(
manifest: &Manifest,
dep: &ResourceDependency,
artifact_type: &str,
resource_type: ResourceType,
) -> bool {
let dep_flatten = dep.get_flatten();
let tool_flatten = manifest
.get_tool_config(artifact_type)
.and_then(|config| config.resources.get(resource_type.to_plural()))
.and_then(|resource_config| resource_config.flatten);
dep_flatten.or(tool_flatten).unwrap_or(false) }
pub fn construct_full_relative_path(base_path: &Path, matched_path: &Path) -> String {
if base_path == Path::new(".") {
crate::utils::normalize_path_for_storage(matched_path.to_string_lossy().to_string())
} else {
crate::utils::normalize_path_for_storage(format!(
"{}/{}",
base_path.display(),
matched_path.display()
))
}
}
pub fn extract_pattern_filename(base_path: &Path, matched_path: &Path) -> String {
let full_path = if base_path == Path::new(".") {
matched_path.to_path_buf()
} else {
base_path.join(matched_path)
};
extract_meaningful_path(&full_path)
}
pub fn extract_meaningful_path(path: &Path) -> String {
let components: Vec<_> = path.components().collect();
if path.is_absolute() {
let mut resolved = Vec::new();
for component in components.iter() {
match component {
std::path::Component::Normal(name) => {
resolved.push(name.to_str().unwrap_or(""));
}
std::path::Component::ParentDir => {
resolved.pop();
}
_ => {}
}
}
resolved.join("/")
} else if components.iter().any(|c| matches!(c, std::path::Component::ParentDir)) {
let start_idx = components
.iter()
.position(|c| matches!(c, std::path::Component::Normal(_)))
.unwrap_or(0);
components[start_idx..]
.iter()
.filter_map(|c| c.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/")
} else {
path.to_str().unwrap_or("").replace('\\', "/") }
}
pub fn is_file_relative_path(path: &str) -> bool {
path.starts_with("./") || path.starts_with("../")
}
pub fn normalize_bare_filename(path: &str) -> String {
let path_buf = Path::new(path);
path_buf.file_name().and_then(|name| name.to_str()).unwrap_or(path).to_string()
}
pub fn resolve_install_path(
manifest: &Manifest,
dep: &ResourceDependency,
artifact_type: &str,
resource_type: ResourceType,
source_filename: &str,
) -> Result<String> {
match resource_type {
ResourceType::Hook | ResourceType::McpServer => {
Ok(resolve_merge_target_path(manifest, artifact_type, resource_type))
}
_ => resolve_regular_resource_path(
manifest,
dep,
artifact_type,
resource_type,
source_filename,
),
}
}
pub fn resolve_merge_target_path(
manifest: &Manifest,
artifact_type: &str,
resource_type: ResourceType,
) -> String {
if let Some(merge_target) = manifest.get_merge_target(artifact_type, resource_type) {
normalize_path_for_storage(merge_target.display().to_string())
} else {
match resource_type {
ResourceType::Hook => ".claude/settings.local.json".to_string(),
ResourceType::McpServer => {
if artifact_type == "opencode" {
".opencode/opencode.json".to_string()
} else {
".mcp.json".to_string()
}
}
_ => unreachable!(
"resolve_merge_target_path should only be called for Hook or McpServer"
),
}
}
}
pub fn resolve_regular_resource_path(
manifest: &Manifest,
dep: &ResourceDependency,
artifact_type: &str,
resource_type: ResourceType,
source_filename: &str,
) -> Result<String> {
let artifact_path =
manifest.get_artifact_resource_path(artifact_type, resource_type).ok_or_else(|| {
create_unsupported_resource_error(artifact_type, resource_type, dep.get_path())
})?;
let path = if let Some(custom_target) = dep.get_target() {
compute_custom_target_path(
&artifact_path,
custom_target,
source_filename,
dep,
manifest,
artifact_type,
resource_type,
)
} else {
compute_default_path(
&artifact_path,
source_filename,
dep,
manifest,
artifact_type,
resource_type,
)
};
Ok(normalize_path_for_storage(path))
}
fn compute_custom_target_path(
artifact_path: &Path,
custom_target: &str,
source_filename: &str,
dep: &ResourceDependency,
manifest: &Manifest,
artifact_type: &str,
resource_type: ResourceType,
) -> PathBuf {
let flatten = get_flatten_behavior(manifest, dep, artifact_type, resource_type);
let base_target = PathBuf::from(artifact_path.display().to_string())
.join(custom_target.trim_start_matches(['/', '\\']));
let relative_path =
compute_relative_install_path(artifact_path, Path::new(source_filename), flatten);
base_target.join(relative_path)
}
fn compute_default_path(
artifact_path: &Path,
source_filename: &str,
dep: &ResourceDependency,
manifest: &Manifest,
artifact_type: &str,
resource_type: ResourceType,
) -> PathBuf {
let flatten = get_flatten_behavior(manifest, dep, artifact_type, resource_type);
let relative_path =
compute_relative_install_path(artifact_path, Path::new(source_filename), flatten);
artifact_path.join(relative_path)
}
fn create_unsupported_resource_error(
artifact_type: &str,
resource_type: ResourceType,
source_path: &str,
) -> anyhow::Error {
let base_msg =
format!("Resource type '{}' is not supported by tool '{}'", resource_type, artifact_type);
let resource_type_str = resource_type.to_string();
let hint = if ["claude-code", "opencode", "agpm"].contains(&resource_type_str.as_str()) {
format!(
"\n\nIt looks like '{}' is a tool name, not a resource type.\n\
In transitive dependencies, use resource types (agents, snippets, commands)\n\
as section headers, then specify 'tool: {}' within each dependency.",
resource_type_str, resource_type_str
)
} else {
format!(
"\n\nValid resource types: agent, command, snippet, hook, mcp-server, script\n\
Source file: {}",
source_path
)
};
anyhow::anyhow!("{}{}", base_msg, hint)
}
pub fn transform_path_for_private(path: &str) -> String {
let path_obj = Path::new(path);
let components: Vec<_> = path_obj.components().collect();
if components.len() >= 2 {
let mut result_components: Vec<String> = components
.iter()
.take(components.len() - 1)
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect();
result_components.push("private".to_string());
if let Some(last) = components.last() {
result_components.push(last.as_os_str().to_string_lossy().to_string());
}
result_components.join("/")
} else {
format!("private/{path}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pattern_base_path_simple() {
let (base, pattern) = parse_pattern_base_path("*.md");
assert_eq!(base, PathBuf::from("."));
assert_eq!(pattern, "*.md");
}
#[test]
fn test_parse_pattern_base_path_with_directory() {
let (base, pattern) = parse_pattern_base_path("agents/*.md");
assert_eq!(base, PathBuf::from("."));
assert_eq!(pattern, "agents/*.md");
}
#[test]
fn test_parse_pattern_base_path_with_parent() {
let (base, pattern) = parse_pattern_base_path("../foo/*.md");
assert_eq!(base, PathBuf::from("../foo"));
assert_eq!(pattern, "*.md");
}
#[test]
fn test_parse_pattern_base_path_with_current_dir() {
let (base, pattern) = parse_pattern_base_path("./foo/*.md");
assert_eq!(base, PathBuf::from("./foo"));
assert_eq!(pattern, "*.md");
}
#[test]
fn test_construct_full_relative_path_current_dir() {
let base = PathBuf::from(".");
let matched = Path::new("agents/helper.md");
let path = construct_full_relative_path(&base, matched);
assert_eq!(path, "agents/helper.md");
}
#[test]
fn test_construct_full_relative_path_with_base() {
let base = PathBuf::from("../foo");
let matched = Path::new("bar.md");
let path = construct_full_relative_path(&base, matched);
assert_eq!(path, "../foo/bar.md");
}
#[test]
fn test_extract_pattern_filename_current_dir() {
let base = PathBuf::from(".");
let matched = Path::new("agents/helper.md");
let filename = extract_pattern_filename(&base, matched);
assert_eq!(filename, "agents/helper.md");
}
}