1use std::collections::HashMap;
33use std::path::{Path, PathBuf};
34use std::sync::Arc;
35
36use rpi_harness::prompt_templates::{
37 load_prompt_templates, LoadPromptTemplatesResult, PromptTemplateDiagnostic,
38 PromptTemplateDiagnosticCode,
39};
40use rpi_harness::skills::{load_skills, LoadSkillsResult, SkillDiagnostic, SkillDiagnosticCode};
41use rpi_harness::types::{PromptTemplate, Skill};
42use rpi_tools::env::ExecutionEnv;
43
44pub const PROJECT_CONFIG_DIR_NAME: &str = ".rpi";
47pub const LEGACY_PROJECT_CONFIG_DIR_NAME: &str = ".pi";
49
50pub fn project_dir(cwd: &Path, sub: &str) -> PathBuf {
52 cwd.join(PROJECT_CONFIG_DIR_NAME).join(sub)
53}
54
55pub fn project_dirs(cwd: &Path, sub: &str) -> Vec<PathBuf> {
58 vec![
59 project_dir(cwd, sub),
60 cwd.join(LEGACY_PROJECT_CONFIG_DIR_NAME).join(sub),
61 ]
62}
63
64pub fn global_dir(sub: &str) -> Option<PathBuf> {
68 crate::config::agent_dir().ok().map(|d| d.join(sub))
69}
70
71pub fn project_config_file(cwd: &Path, name: &str) -> PathBuf {
77 cwd.join(PROJECT_CONFIG_DIR_NAME).join(name)
78}
79
80pub fn project_config_files(cwd: &Path, name: &str) -> Vec<PathBuf> {
83 vec![
84 project_config_file(cwd, name),
85 cwd.join(LEGACY_PROJECT_CONFIG_DIR_NAME).join(name),
86 ]
87}
88
89pub fn global_config_file(name: &str) -> Option<PathBuf> {
91 crate::config::agent_dir().ok().map(|d| d.join(name))
92}
93
94pub fn discover_system_prompt_file(cwd: &Path) -> Option<PathBuf> {
113 discover_system_prompt_file_with_packages(cwd, &crate::packages::PackageResources::default())
114}
115
116pub fn discover_system_prompt_file_with_packages(
120 cwd: &Path,
121 packages: &crate::packages::PackageResources,
122) -> Option<PathBuf> {
123 for project in project_config_files(cwd, "SYSTEM.md") {
124 if project.is_file() {
125 return Some(project);
126 }
127 }
128 if let Some(global) = global_config_file("SYSTEM.md").filter(|p| p.is_file()) {
129 return Some(global);
130 }
131 packages
132 .system_prompt_files()
133 .into_iter()
134 .find(|path| path.is_file())
135}
136
137pub fn discover_append_system_prompt_file(cwd: &Path) -> Option<PathBuf> {
152 discover_append_system_prompt_file_with_packages(
153 cwd,
154 &crate::packages::PackageResources::default(),
155 )
156}
157
158pub fn discover_append_system_prompt_file_with_packages(
160 cwd: &Path,
161 packages: &crate::packages::PackageResources,
162) -> Option<PathBuf> {
163 for project in project_config_files(cwd, "APPEND_SYSTEM.md") {
164 if project.is_file() {
165 return Some(project);
166 }
167 }
168 if let Some(global) = global_config_file("APPEND_SYSTEM.md").filter(|p| p.is_file()) {
169 return Some(global);
170 }
171 packages
172 .append_system_prompt_files()
173 .into_iter()
174 .find(|path| path.is_file())
175}
176
177pub fn dedupe_skills(skills: Vec<Skill>, diagnostics: &mut Vec<SkillDiagnostic>) -> Vec<Skill> {
191 let mut winner_path: HashMap<String, String> = HashMap::new();
192 let mut out: Vec<Skill> = Vec::with_capacity(skills.len());
193 for skill in skills {
194 if let Some(winner) = winner_path.get(&skill.name) {
195 diagnostics.push(SkillDiagnostic {
196 code: SkillDiagnosticCode::InvalidMetadata,
197 message: format!(
198 "Skill name \"{}\" from {} is shadowed by {} \
199 (first-registration wins; load project before global so project wins)",
200 skill.name, skill.file_path, winner
201 ),
202 path: skill.file_path.clone(),
203 });
204 } else {
205 winner_path.insert(skill.name.clone(), skill.file_path.clone());
206 out.push(skill);
207 }
208 }
209 out
210}
211
212pub fn dedupe_prompt_templates(
223 templates: Vec<PromptTemplate>,
224 diagnostics: &mut Vec<PromptTemplateDiagnostic>,
225) -> Vec<PromptTemplate> {
226 let mut seen: HashMap<String, ()> = HashMap::new();
227 let mut out: Vec<PromptTemplate> = Vec::with_capacity(templates.len());
228 for t in templates {
229 if seen.contains_key(&t.name) {
230 diagnostics.push(PromptTemplateDiagnostic {
231 code: PromptTemplateDiagnosticCode::ParseFailed,
232 message: format!(
233 "Prompt template name \"{}\" is shadowed by an earlier registration \
234 (first-registration wins; load project before global so project wins)",
235 t.name
236 ),
237 path: t.name.clone(),
240 });
241 } else {
242 seen.insert(t.name.clone(), ());
243 out.push(t);
244 }
245 }
246 out
247}
248
249pub async fn load_skills_with_precedence(
258 env: &Arc<dyn ExecutionEnv>,
259 dirs: &[PathBuf],
260) -> LoadSkillsResult {
261 let dir_strs: Vec<String> = dirs
262 .iter()
263 .map(|d| d.to_string_lossy().into_owned())
264 .collect();
265 let mut result = load_skills(env, &dir_strs).await;
266 result.skills = dedupe_skills(result.skills, &mut result.diagnostics);
267 result
268}
269
270pub async fn load_prompt_templates_with_precedence(
274 env: &Arc<dyn ExecutionEnv>,
275 paths: &[PathBuf],
276) -> LoadPromptTemplatesResult {
277 let path_strs: Vec<String> = paths
278 .iter()
279 .map(|p| p.to_string_lossy().into_owned())
280 .collect();
281 let mut result = load_prompt_templates(env, &path_strs).await;
282 result.prompt_templates =
283 dedupe_prompt_templates(result.prompt_templates, &mut result.diagnostics);
284 result
285}
286
287pub fn skill_dirs(cwd: &Path) -> Vec<PathBuf> {
292 let mut dirs = project_resource_dirs(cwd, "skills", ResourceKind::Skills);
293 if let Some(g) = global_dir("skills") {
294 dirs.extend(configured_global_dirs(g.clone(), ResourceKind::Skills));
295 dirs.push(g);
296 }
297 dirs
298}
299
300pub fn global_skill_dirs() -> Vec<PathBuf> {
303 global_resource_dirs("skills", ResourceKind::Skills)
304}
305
306pub fn prompt_template_dirs(cwd: &Path) -> Vec<PathBuf> {
309 let mut dirs = project_resource_dirs(cwd, "prompts", ResourceKind::Prompts);
310 if let Some(g) = global_dir("prompts") {
311 dirs.extend(configured_global_dirs(g.clone(), ResourceKind::Prompts));
312 dirs.push(g);
313 }
314 dirs
315}
316
317pub fn global_prompt_template_dirs() -> Vec<PathBuf> {
318 global_resource_dirs("prompts", ResourceKind::Prompts)
319}
320
321pub fn extension_dirs(cwd: &Path) -> Vec<PathBuf> {
325 let mut dirs = project_resource_dirs(cwd, "extensions", ResourceKind::Extensions);
326 if let Some(g) = global_dir("extensions") {
327 dirs.extend(configured_global_dirs(g.clone(), ResourceKind::Extensions));
328 dirs.push(g);
329 }
330 dirs
331}
332
333pub fn global_extension_dirs() -> Vec<PathBuf> {
334 global_resource_dirs("extensions", ResourceKind::Extensions)
335}
336
337#[derive(Clone, Copy)]
338enum ResourceKind {
339 Skills,
340 Prompts,
341 Extensions,
342}
343
344fn project_resource_dirs(cwd: &Path, sub: &str, kind: ResourceKind) -> Vec<PathBuf> {
345 let loaded = crate::settings::load_project_settings_with_paths(cwd);
346 let mut dirs = Vec::new();
347 for config_name in [".rpi", ".pi"] {
348 if let Some((_, settings)) = loaded.iter().find(|(path, _)| {
349 path.parent()
350 .and_then(Path::file_name)
351 .and_then(|name| name.to_str())
352 == Some(config_name)
353 }) {
354 dirs.extend(configured_paths(settings, cwd, kind));
355 }
356 dirs.push(cwd.join(config_name).join(sub));
357 }
358 dirs
359}
360
361fn configured_global_dirs(agent_dir: PathBuf, kind: ResourceKind) -> Vec<PathBuf> {
362 crate::settings::load_settings()
363 .ok()
364 .into_iter()
365 .flat_map(|settings| configured_paths(&settings, &agent_dir, kind))
366 .collect()
367}
368
369fn global_resource_dirs(sub: &str, kind: ResourceKind) -> Vec<PathBuf> {
370 let Some(g) = global_dir(sub) else {
371 return Vec::new();
372 };
373 let mut dirs = configured_global_dirs(g.clone(), kind);
374 dirs.push(g);
375 dirs
376}
377
378fn configured_paths(
379 settings: &crate::settings::Settings,
380 base: &Path,
381 kind: ResourceKind,
382) -> Vec<PathBuf> {
383 let values = match kind {
384 ResourceKind::Skills => settings.skill_dirs.as_ref(),
385 ResourceKind::Prompts => settings.prompt_dirs.as_ref(),
386 ResourceKind::Extensions => settings.extension_dirs.as_ref(),
387 };
388 values
389 .map(|paths| crate::settings::resolve_configured_paths(base, paths))
390 .unwrap_or_default()
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 fn skill(name: &str, path: &str) -> Skill {
398 Skill {
399 name: name.to_string(),
400 description: "d".to_string(),
401 content: "c".to_string(),
402 file_path: path.to_string(),
403 disable_model_invocation: None,
404 }
405 }
406
407 fn tmpl(name: &str) -> PromptTemplate {
408 PromptTemplate {
409 name: name.to_string(),
410 description: None,
411 content: "c".to_string(),
412 }
413 }
414
415 #[test]
416 fn dedupe_skills_first_wins_keeps_project() {
417 let skills = vec![
419 skill("echo", "/proj/.pi/skills/echo/SKILL.md"),
420 skill("echo", "/home/.rpi/agent/skills/echo/SKILL.md"),
421 ];
422 let mut diags = Vec::new();
423 let out = dedupe_skills(skills, &mut diags);
424 assert_eq!(out.len(), 1);
425 assert_eq!(out[0].file_path, "/proj/.pi/skills/echo/SKILL.md");
426 assert_eq!(diags.len(), 1);
427 assert!(diags[0]
428 .message
429 .contains("/home/.rpi/agent/skills/echo/SKILL.md"));
430 assert!(diags[0].message.contains("/proj/.pi/skills/echo/SKILL.md"));
431 assert_eq!(diags[0].path, "/home/.rpi/agent/skills/echo/SKILL.md");
432 }
433
434 #[test]
435 fn dedupe_skills_distinct_names_all_kept() {
436 let skills = vec![skill("a", "/p/a"), skill("b", "/p/b"), skill("c", "/g/c")];
437 let mut diags = Vec::new();
438 let out = dedupe_skills(skills, &mut diags);
439 assert_eq!(out.len(), 3);
440 assert!(diags.is_empty());
441 }
442
443 #[test]
444 fn dedupe_skills_third_duplicate_drops_against_first() {
445 let skills = vec![
446 skill("x", "/proj/x"),
447 skill("x", "/global/x"),
448 skill("x", "/pkg/x"),
449 ];
450 let mut diags = Vec::new();
451 let out = dedupe_skills(skills, &mut diags);
452 assert_eq!(out.len(), 1);
453 assert_eq!(out[0].file_path, "/proj/x");
454 assert_eq!(diags.len(), 2);
456 }
457
458 #[test]
459 fn dedupe_skills_empty_input() {
460 let mut diags = Vec::new();
461 let out = dedupe_skills(Vec::new(), &mut diags);
462 assert!(out.is_empty());
463 assert!(diags.is_empty());
464 }
465
466 #[test]
467 fn dedupe_prompts_first_wins() {
468 let templates = vec![tmpl("greet"), tmpl("greet")];
469 let mut diags = Vec::new();
470 let out = dedupe_prompt_templates(templates, &mut diags);
471 assert_eq!(out.len(), 1);
472 assert_eq!(out[0].name, "greet");
473 assert_eq!(diags.len(), 1);
474 assert_eq!(diags[0].path, "greet");
475 }
476
477 #[test]
478 fn dedupe_prompts_distinct_all_kept() {
479 let templates = vec![tmpl("a"), tmpl("b"), tmpl("c")];
480 let mut diags = Vec::new();
481 let out = dedupe_prompt_templates(templates, &mut diags);
482 assert_eq!(out.len(), 3);
483 assert!(diags.is_empty());
484 }
485
486 #[test]
487 fn project_dir_uses_rpi_name() {
488 let d = project_dir(Path::new("/proj"), "skills");
489 assert_eq!(d, PathBuf::from("/proj/.rpi/skills"));
490 }
491
492 #[test]
493 fn project_dirs_keep_pi_compatibility_after_rpi() {
494 let dirs = project_dirs(Path::new("/proj"), "extensions");
495 assert_eq!(
496 dirs,
497 vec![
498 PathBuf::from("/proj/.rpi/extensions"),
499 PathBuf::from("/proj/.pi/extensions")
500 ]
501 );
502 }
503
504 #[test]
505 fn project_config_file_prefers_rpi_and_keeps_pi_fallback() {
506 let p = project_config_file(Path::new("/proj"), "SYSTEM.md");
507 assert_eq!(p, PathBuf::from("/proj/.rpi/SYSTEM.md"));
508 assert_eq!(
509 project_config_files(Path::new("/proj"), "SYSTEM.md"),
510 vec![
511 PathBuf::from("/proj/.rpi/SYSTEM.md"),
512 PathBuf::from("/proj/.pi/SYSTEM.md")
513 ]
514 );
515 }
516
517 #[test]
518 fn project_settings_add_configured_resource_paths() {
519 let tmp = tempfile::tempdir().unwrap();
520 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
521 std::fs::write(
522 tmp.path().join(".rpi/settings.json"),
523 r#"{"skills":["shared-skills"],"promptDirs":["prompt-pack"],"extensionDirs":["target/debug"]}"#,
524 )
525 .unwrap();
526
527 let skills = skill_dirs(tmp.path());
528 assert_eq!(skills[0], tmp.path().join("shared-skills"));
529 assert!(skills.contains(&tmp.path().join(".rpi/skills")));
530
531 let prompts = prompt_template_dirs(tmp.path());
532 assert_eq!(prompts[0], tmp.path().join("prompt-pack"));
533
534 let extensions = extension_dirs(tmp.path());
535 assert_eq!(extensions[0], tmp.path().join("target/debug"));
536 assert!(extensions.contains(&tmp.path().join(".rpi/extensions")));
537 }
538}