use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use rpi_harness::prompt_templates::{
load_prompt_templates, LoadPromptTemplatesResult, PromptTemplateDiagnostic,
PromptTemplateDiagnosticCode,
};
use rpi_harness::skills::{load_skills, LoadSkillsResult, SkillDiagnostic, SkillDiagnosticCode};
use rpi_harness::types::{PromptTemplate, Skill};
use rpi_tools::env::ExecutionEnv;
pub const PROJECT_CONFIG_DIR_NAME: &str = ".rpi";
pub const LEGACY_PROJECT_CONFIG_DIR_NAME: &str = ".pi";
pub fn project_dir(cwd: &Path, sub: &str) -> PathBuf {
cwd.join(PROJECT_CONFIG_DIR_NAME).join(sub)
}
pub fn project_dirs(cwd: &Path, sub: &str) -> Vec<PathBuf> {
vec![
project_dir(cwd, sub),
cwd.join(LEGACY_PROJECT_CONFIG_DIR_NAME).join(sub),
]
}
pub fn global_dir(sub: &str) -> Option<PathBuf> {
crate::config::agent_dir().ok().map(|d| d.join(sub))
}
pub fn project_config_file(cwd: &Path, name: &str) -> PathBuf {
cwd.join(PROJECT_CONFIG_DIR_NAME).join(name)
}
pub fn project_config_files(cwd: &Path, name: &str) -> Vec<PathBuf> {
vec![
project_config_file(cwd, name),
cwd.join(LEGACY_PROJECT_CONFIG_DIR_NAME).join(name),
]
}
pub fn global_config_file(name: &str) -> Option<PathBuf> {
crate::config::agent_dir().ok().map(|d| d.join(name))
}
pub fn discover_system_prompt_file(cwd: &Path) -> Option<PathBuf> {
discover_system_prompt_file_with_packages(cwd, &crate::packages::PackageResources::default())
}
pub fn discover_system_prompt_file_with_packages(
cwd: &Path,
packages: &crate::packages::PackageResources,
) -> Option<PathBuf> {
for project in project_config_files(cwd, "SYSTEM.md") {
if project.is_file() {
return Some(project);
}
}
if let Some(global) = global_config_file("SYSTEM.md").filter(|p| p.is_file()) {
return Some(global);
}
packages
.system_prompt_files()
.into_iter()
.find(|path| path.is_file())
}
pub fn discover_append_system_prompt_file(cwd: &Path) -> Option<PathBuf> {
discover_append_system_prompt_file_with_packages(
cwd,
&crate::packages::PackageResources::default(),
)
}
pub fn discover_append_system_prompt_file_with_packages(
cwd: &Path,
packages: &crate::packages::PackageResources,
) -> Option<PathBuf> {
for project in project_config_files(cwd, "APPEND_SYSTEM.md") {
if project.is_file() {
return Some(project);
}
}
if let Some(global) = global_config_file("APPEND_SYSTEM.md").filter(|p| p.is_file()) {
return Some(global);
}
packages
.append_system_prompt_files()
.into_iter()
.find(|path| path.is_file())
}
pub fn dedupe_skills(skills: Vec<Skill>, diagnostics: &mut Vec<SkillDiagnostic>) -> Vec<Skill> {
let mut winner_path: HashMap<String, String> = HashMap::new();
let mut out: Vec<Skill> = Vec::with_capacity(skills.len());
for skill in skills {
if let Some(winner) = winner_path.get(&skill.name) {
diagnostics.push(SkillDiagnostic {
code: SkillDiagnosticCode::InvalidMetadata,
message: format!(
"Skill name \"{}\" from {} is shadowed by {} \
(first-registration wins; load project before global so project wins)",
skill.name, skill.file_path, winner
),
path: skill.file_path.clone(),
});
} else {
winner_path.insert(skill.name.clone(), skill.file_path.clone());
out.push(skill);
}
}
out
}
pub fn dedupe_prompt_templates(
templates: Vec<PromptTemplate>,
diagnostics: &mut Vec<PromptTemplateDiagnostic>,
) -> Vec<PromptTemplate> {
let mut seen: HashMap<String, ()> = HashMap::new();
let mut out: Vec<PromptTemplate> = Vec::with_capacity(templates.len());
for t in templates {
if seen.contains_key(&t.name) {
diagnostics.push(PromptTemplateDiagnostic {
code: PromptTemplateDiagnosticCode::ParseFailed,
message: format!(
"Prompt template name \"{}\" is shadowed by an earlier registration \
(first-registration wins; load project before global so project wins)",
t.name
),
path: t.name.clone(),
});
} else {
seen.insert(t.name.clone(), ());
out.push(t);
}
}
out
}
pub async fn load_skills_with_precedence(
env: &Arc<dyn ExecutionEnv>,
dirs: &[PathBuf],
) -> LoadSkillsResult {
let dir_strs: Vec<String> = dirs
.iter()
.map(|d| d.to_string_lossy().into_owned())
.collect();
let mut result = load_skills(env, &dir_strs).await;
result.skills = dedupe_skills(result.skills, &mut result.diagnostics);
result
}
pub async fn load_prompt_templates_with_precedence(
env: &Arc<dyn ExecutionEnv>,
paths: &[PathBuf],
) -> LoadPromptTemplatesResult {
let path_strs: Vec<String> = paths
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
let mut result = load_prompt_templates(env, &path_strs).await;
result.prompt_templates =
dedupe_prompt_templates(result.prompt_templates, &mut result.diagnostics);
result
}
pub fn skill_dirs(cwd: &Path) -> Vec<PathBuf> {
let mut dirs = project_skill_dirs(cwd);
if let Some(g) = global_dir("skills") {
dirs.extend(configured_global_dirs(g.clone(), ResourceKind::Skills));
dirs.push(g);
}
dirs
}
pub fn project_skill_dirs(cwd: &Path) -> Vec<PathBuf> {
project_resource_dirs(cwd, "skills", ResourceKind::Skills)
}
pub fn global_skill_dirs() -> Vec<PathBuf> {
global_resource_dirs("skills", ResourceKind::Skills)
}
pub fn prompt_template_dirs(cwd: &Path) -> Vec<PathBuf> {
let mut dirs = project_prompt_template_dirs(cwd);
if let Some(g) = global_dir("prompts") {
dirs.extend(configured_global_dirs(g.clone(), ResourceKind::Prompts));
dirs.push(g);
}
dirs
}
pub fn project_prompt_template_dirs(cwd: &Path) -> Vec<PathBuf> {
project_resource_dirs(cwd, "prompts", ResourceKind::Prompts)
}
pub fn global_prompt_template_dirs() -> Vec<PathBuf> {
global_resource_dirs("prompts", ResourceKind::Prompts)
}
pub fn extension_dirs(cwd: &Path) -> Vec<PathBuf> {
let mut dirs = project_resource_dirs(cwd, "extensions", ResourceKind::Extensions);
if let Some(g) = global_dir("extensions") {
dirs.extend(configured_global_dirs(g.clone(), ResourceKind::Extensions));
dirs.push(g);
}
dirs
}
pub fn global_extension_dirs() -> Vec<PathBuf> {
global_resource_dirs("extensions", ResourceKind::Extensions)
}
#[derive(Clone, Copy)]
enum ResourceKind {
Skills,
Prompts,
Extensions,
}
fn project_resource_dirs(cwd: &Path, sub: &str, kind: ResourceKind) -> Vec<PathBuf> {
let loaded = crate::settings::load_project_settings_with_paths(cwd);
let mut dirs = Vec::new();
for config_name in [".rpi", ".pi"] {
if let Some((_, settings)) = loaded.iter().find(|(path, _)| {
path.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
== Some(config_name)
}) {
dirs.extend(configured_paths(settings, cwd, kind));
}
dirs.push(cwd.join(config_name).join(sub));
}
dirs
}
fn configured_global_dirs(agent_dir: PathBuf, kind: ResourceKind) -> Vec<PathBuf> {
crate::settings::load_settings()
.ok()
.into_iter()
.flat_map(|settings| configured_paths(&settings, &agent_dir, kind))
.collect()
}
fn global_resource_dirs(sub: &str, kind: ResourceKind) -> Vec<PathBuf> {
let Some(g) = global_dir(sub) else {
return Vec::new();
};
let mut dirs = configured_global_dirs(g.clone(), kind);
dirs.push(g);
dirs
}
fn configured_paths(
settings: &crate::settings::Settings,
base: &Path,
kind: ResourceKind,
) -> Vec<PathBuf> {
let values = match kind {
ResourceKind::Skills => settings.skill_dirs.as_ref(),
ResourceKind::Prompts => settings.prompt_dirs.as_ref(),
ResourceKind::Extensions => settings.extension_dirs.as_ref(),
};
values
.map(|paths| crate::settings::resolve_configured_paths(base, paths))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn skill(name: &str, path: &str) -> Skill {
Skill {
name: name.to_string(),
description: "d".to_string(),
content: "c".to_string(),
file_path: path.to_string(),
disable_model_invocation: None,
}
}
fn tmpl(name: &str) -> PromptTemplate {
PromptTemplate {
name: name.to_string(),
description: None,
content: "c".to_string(),
}
}
#[test]
fn dedupe_skills_first_wins_keeps_project() {
let skills = vec![
skill("echo", "/proj/.pi/skills/echo/SKILL.md"),
skill("echo", "/home/.rpi/agent/skills/echo/SKILL.md"),
];
let mut diags = Vec::new();
let out = dedupe_skills(skills, &mut diags);
assert_eq!(out.len(), 1);
assert_eq!(out[0].file_path, "/proj/.pi/skills/echo/SKILL.md");
assert_eq!(diags.len(), 1);
assert!(diags[0]
.message
.contains("/home/.rpi/agent/skills/echo/SKILL.md"));
assert!(diags[0].message.contains("/proj/.pi/skills/echo/SKILL.md"));
assert_eq!(diags[0].path, "/home/.rpi/agent/skills/echo/SKILL.md");
}
#[test]
fn dedupe_skills_distinct_names_all_kept() {
let skills = vec![skill("a", "/p/a"), skill("b", "/p/b"), skill("c", "/g/c")];
let mut diags = Vec::new();
let out = dedupe_skills(skills, &mut diags);
assert_eq!(out.len(), 3);
assert!(diags.is_empty());
}
#[test]
fn dedupe_skills_third_duplicate_drops_against_first() {
let skills = vec![
skill("x", "/proj/x"),
skill("x", "/global/x"),
skill("x", "/pkg/x"),
];
let mut diags = Vec::new();
let out = dedupe_skills(skills, &mut diags);
assert_eq!(out.len(), 1);
assert_eq!(out[0].file_path, "/proj/x");
assert_eq!(diags.len(), 2);
}
#[test]
fn dedupe_skills_empty_input() {
let mut diags = Vec::new();
let out = dedupe_skills(Vec::new(), &mut diags);
assert!(out.is_empty());
assert!(diags.is_empty());
}
#[test]
fn dedupe_prompts_first_wins() {
let templates = vec![tmpl("greet"), tmpl("greet")];
let mut diags = Vec::new();
let out = dedupe_prompt_templates(templates, &mut diags);
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "greet");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].path, "greet");
}
#[test]
fn dedupe_prompts_distinct_all_kept() {
let templates = vec![tmpl("a"), tmpl("b"), tmpl("c")];
let mut diags = Vec::new();
let out = dedupe_prompt_templates(templates, &mut diags);
assert_eq!(out.len(), 3);
assert!(diags.is_empty());
}
#[test]
fn project_dir_uses_rpi_name() {
let d = project_dir(Path::new("/proj"), "skills");
assert_eq!(d, PathBuf::from("/proj/.rpi/skills"));
}
#[test]
fn project_dirs_keep_pi_compatibility_after_rpi() {
let dirs = project_dirs(Path::new("/proj"), "extensions");
assert_eq!(
dirs,
vec![
PathBuf::from("/proj/.rpi/extensions"),
PathBuf::from("/proj/.pi/extensions")
]
);
}
#[test]
fn project_config_file_prefers_rpi_and_keeps_pi_fallback() {
let p = project_config_file(Path::new("/proj"), "SYSTEM.md");
assert_eq!(p, PathBuf::from("/proj/.rpi/SYSTEM.md"));
assert_eq!(
project_config_files(Path::new("/proj"), "SYSTEM.md"),
vec![
PathBuf::from("/proj/.rpi/SYSTEM.md"),
PathBuf::from("/proj/.pi/SYSTEM.md")
]
);
}
#[test]
fn project_settings_add_configured_resource_paths() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
std::fs::write(
tmp.path().join(".rpi/settings.json"),
r#"{"skills":["shared-skills"],"promptDirs":["prompt-pack"],"extensionDirs":["target/debug"]}"#,
)
.unwrap();
let skills = skill_dirs(tmp.path());
assert_eq!(skills[0], tmp.path().join("shared-skills"));
assert!(skills.contains(&tmp.path().join(".rpi/skills")));
let prompts = prompt_template_dirs(tmp.path());
assert_eq!(prompts[0], tmp.path().join("prompt-pack"));
let extensions = extension_dirs(tmp.path());
assert_eq!(extensions[0], tmp.path().join("target/debug"));
assert!(extensions.contains(&tmp.path().join(".rpi/extensions")));
}
#[test]
fn local_resource_dirs_exclude_global_paths() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
std::fs::write(
tmp.path().join(".rpi/settings.json"),
r#"{"skills":["local-skills"],"promptDirs":["local-prompts"]}"#,
)
.unwrap();
let skills = project_skill_dirs(tmp.path());
let prompts = project_prompt_template_dirs(tmp.path());
assert!(skills.contains(&tmp.path().join("local-skills")));
assert!(skills.contains(&tmp.path().join(".rpi/skills")));
assert!(prompts.contains(&tmp.path().join("local-prompts")));
assert!(prompts.contains(&tmp.path().join(".rpi/prompts")));
}
}