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_skill_dirs(cwd);
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 project_skill_dirs(cwd: &Path) -> Vec<PathBuf> {
304 project_resource_dirs(cwd, "skills", ResourceKind::Skills)
305}
306
307pub fn global_skill_dirs() -> Vec<PathBuf> {
310 global_resource_dirs("skills", ResourceKind::Skills)
311}
312
313pub fn prompt_template_dirs(cwd: &Path) -> Vec<PathBuf> {
316 let mut dirs = project_prompt_template_dirs(cwd);
317 if let Some(g) = global_dir("prompts") {
318 dirs.extend(configured_global_dirs(g.clone(), ResourceKind::Prompts));
319 dirs.push(g);
320 }
321 dirs
322}
323
324pub fn project_prompt_template_dirs(cwd: &Path) -> Vec<PathBuf> {
326 project_resource_dirs(cwd, "prompts", ResourceKind::Prompts)
327}
328
329pub fn global_prompt_template_dirs() -> Vec<PathBuf> {
330 global_resource_dirs("prompts", ResourceKind::Prompts)
331}
332
333pub fn extension_dirs(cwd: &Path) -> Vec<PathBuf> {
337 let mut dirs = project_resource_dirs(cwd, "extensions", ResourceKind::Extensions);
338 if let Some(g) = global_dir("extensions") {
339 dirs.extend(configured_global_dirs(g.clone(), ResourceKind::Extensions));
340 dirs.push(g);
341 }
342 dirs
343}
344
345pub fn global_extension_dirs() -> Vec<PathBuf> {
346 global_resource_dirs("extensions", ResourceKind::Extensions)
347}
348
349#[derive(Clone, Copy)]
350enum ResourceKind {
351 Skills,
352 Prompts,
353 Extensions,
354}
355
356fn project_resource_dirs(cwd: &Path, sub: &str, kind: ResourceKind) -> Vec<PathBuf> {
357 let loaded = crate::settings::load_project_settings_with_paths(cwd);
358 let mut dirs = Vec::new();
359 for config_name in [".rpi", ".pi"] {
360 if let Some((_, settings)) = loaded.iter().find(|(path, _)| {
361 path.parent()
362 .and_then(Path::file_name)
363 .and_then(|name| name.to_str())
364 == Some(config_name)
365 }) {
366 dirs.extend(configured_paths(settings, cwd, kind));
367 }
368 dirs.push(cwd.join(config_name).join(sub));
369 }
370 dirs
371}
372
373fn configured_global_dirs(agent_dir: PathBuf, kind: ResourceKind) -> Vec<PathBuf> {
374 crate::settings::load_settings()
375 .ok()
376 .into_iter()
377 .flat_map(|settings| configured_paths(&settings, &agent_dir, kind))
378 .collect()
379}
380
381fn global_resource_dirs(sub: &str, kind: ResourceKind) -> Vec<PathBuf> {
382 let Some(g) = global_dir(sub) else {
383 return Vec::new();
384 };
385 let mut dirs = configured_global_dirs(g.clone(), kind);
386 dirs.push(g);
387 dirs
388}
389
390fn configured_paths(
391 settings: &crate::settings::Settings,
392 base: &Path,
393 kind: ResourceKind,
394) -> Vec<PathBuf> {
395 let values = match kind {
396 ResourceKind::Skills => settings.skill_dirs.as_ref(),
397 ResourceKind::Prompts => settings.prompt_dirs.as_ref(),
398 ResourceKind::Extensions => settings.extension_dirs.as_ref(),
399 };
400 values
401 .map(|paths| crate::settings::resolve_configured_paths(base, paths))
402 .unwrap_or_default()
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 fn skill(name: &str, path: &str) -> Skill {
410 Skill {
411 name: name.to_string(),
412 description: "d".to_string(),
413 content: "c".to_string(),
414 file_path: path.to_string(),
415 disable_model_invocation: None,
416 }
417 }
418
419 fn tmpl(name: &str) -> PromptTemplate {
420 PromptTemplate {
421 name: name.to_string(),
422 description: None,
423 content: "c".to_string(),
424 }
425 }
426
427 #[test]
428 fn dedupe_skills_first_wins_keeps_project() {
429 let skills = vec![
431 skill("echo", "/proj/.pi/skills/echo/SKILL.md"),
432 skill("echo", "/home/.rpi/agent/skills/echo/SKILL.md"),
433 ];
434 let mut diags = Vec::new();
435 let out = dedupe_skills(skills, &mut diags);
436 assert_eq!(out.len(), 1);
437 assert_eq!(out[0].file_path, "/proj/.pi/skills/echo/SKILL.md");
438 assert_eq!(diags.len(), 1);
439 assert!(diags[0]
440 .message
441 .contains("/home/.rpi/agent/skills/echo/SKILL.md"));
442 assert!(diags[0].message.contains("/proj/.pi/skills/echo/SKILL.md"));
443 assert_eq!(diags[0].path, "/home/.rpi/agent/skills/echo/SKILL.md");
444 }
445
446 #[test]
447 fn dedupe_skills_distinct_names_all_kept() {
448 let skills = vec![skill("a", "/p/a"), skill("b", "/p/b"), skill("c", "/g/c")];
449 let mut diags = Vec::new();
450 let out = dedupe_skills(skills, &mut diags);
451 assert_eq!(out.len(), 3);
452 assert!(diags.is_empty());
453 }
454
455 #[test]
456 fn dedupe_skills_third_duplicate_drops_against_first() {
457 let skills = vec![
458 skill("x", "/proj/x"),
459 skill("x", "/global/x"),
460 skill("x", "/pkg/x"),
461 ];
462 let mut diags = Vec::new();
463 let out = dedupe_skills(skills, &mut diags);
464 assert_eq!(out.len(), 1);
465 assert_eq!(out[0].file_path, "/proj/x");
466 assert_eq!(diags.len(), 2);
468 }
469
470 #[test]
471 fn dedupe_skills_empty_input() {
472 let mut diags = Vec::new();
473 let out = dedupe_skills(Vec::new(), &mut diags);
474 assert!(out.is_empty());
475 assert!(diags.is_empty());
476 }
477
478 #[test]
479 fn dedupe_prompts_first_wins() {
480 let templates = vec![tmpl("greet"), tmpl("greet")];
481 let mut diags = Vec::new();
482 let out = dedupe_prompt_templates(templates, &mut diags);
483 assert_eq!(out.len(), 1);
484 assert_eq!(out[0].name, "greet");
485 assert_eq!(diags.len(), 1);
486 assert_eq!(diags[0].path, "greet");
487 }
488
489 #[test]
490 fn dedupe_prompts_distinct_all_kept() {
491 let templates = vec![tmpl("a"), tmpl("b"), tmpl("c")];
492 let mut diags = Vec::new();
493 let out = dedupe_prompt_templates(templates, &mut diags);
494 assert_eq!(out.len(), 3);
495 assert!(diags.is_empty());
496 }
497
498 #[test]
499 fn project_dir_uses_rpi_name() {
500 let d = project_dir(Path::new("/proj"), "skills");
501 assert_eq!(d, PathBuf::from("/proj/.rpi/skills"));
502 }
503
504 #[test]
505 fn project_dirs_keep_pi_compatibility_after_rpi() {
506 let dirs = project_dirs(Path::new("/proj"), "extensions");
507 assert_eq!(
508 dirs,
509 vec![
510 PathBuf::from("/proj/.rpi/extensions"),
511 PathBuf::from("/proj/.pi/extensions")
512 ]
513 );
514 }
515
516 #[test]
517 fn project_config_file_prefers_rpi_and_keeps_pi_fallback() {
518 let p = project_config_file(Path::new("/proj"), "SYSTEM.md");
519 assert_eq!(p, PathBuf::from("/proj/.rpi/SYSTEM.md"));
520 assert_eq!(
521 project_config_files(Path::new("/proj"), "SYSTEM.md"),
522 vec![
523 PathBuf::from("/proj/.rpi/SYSTEM.md"),
524 PathBuf::from("/proj/.pi/SYSTEM.md")
525 ]
526 );
527 }
528
529 #[test]
530 fn project_settings_add_configured_resource_paths() {
531 let tmp = tempfile::tempdir().unwrap();
532 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
533 std::fs::write(
534 tmp.path().join(".rpi/settings.json"),
535 r#"{"skills":["shared-skills"],"promptDirs":["prompt-pack"],"extensionDirs":["target/debug"]}"#,
536 )
537 .unwrap();
538
539 let skills = skill_dirs(tmp.path());
540 assert_eq!(skills[0], tmp.path().join("shared-skills"));
541 assert!(skills.contains(&tmp.path().join(".rpi/skills")));
542
543 let prompts = prompt_template_dirs(tmp.path());
544 assert_eq!(prompts[0], tmp.path().join("prompt-pack"));
545
546 let extensions = extension_dirs(tmp.path());
547 assert_eq!(extensions[0], tmp.path().join("target/debug"));
548 assert!(extensions.contains(&tmp.path().join(".rpi/extensions")));
549 }
550
551 #[test]
552 fn local_resource_dirs_exclude_global_paths() {
553 let tmp = tempfile::tempdir().unwrap();
554 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
555 std::fs::write(
556 tmp.path().join(".rpi/settings.json"),
557 r#"{"skills":["local-skills"],"promptDirs":["local-prompts"]}"#,
558 )
559 .unwrap();
560
561 let skills = project_skill_dirs(tmp.path());
562 let prompts = project_prompt_template_dirs(tmp.path());
563 assert!(skills.contains(&tmp.path().join("local-skills")));
564 assert!(skills.contains(&tmp.path().join(".rpi/skills")));
565 assert!(prompts.contains(&tmp.path().join("local-prompts")));
566 assert!(prompts.contains(&tmp.path().join(".rpi/prompts")));
567 }
568}