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> {
112 discover_system_prompt_file_with_trust(cwd, true, &crate::packages::PackageResources::default())
113}
114
115pub fn discover_system_prompt_file_with_trust(
119 cwd: &Path,
120 project_trusted: bool,
121 packages: &crate::packages::PackageResources,
122) -> Option<PathBuf> {
123 if project_trusted {
124 for project in project_config_files(cwd, "SYSTEM.md") {
125 if project.is_file() {
126 return Some(project);
127 }
128 }
129 }
130 if let Some(global) = global_config_file("SYSTEM.md").filter(|p| p.is_file()) {
131 return Some(global);
132 }
133 packages
134 .system_prompt_files()
135 .into_iter()
136 .find(|path| path.is_file())
137}
138
139pub fn discover_system_prompt_file_with_packages(
143 cwd: &Path,
144 packages: &crate::packages::PackageResources,
145) -> Option<PathBuf> {
146 for project in project_config_files(cwd, "SYSTEM.md") {
147 if project.is_file() {
148 return Some(project);
149 }
150 }
151 if let Some(global) = global_config_file("SYSTEM.md").filter(|p| p.is_file()) {
152 return Some(global);
153 }
154 packages
155 .system_prompt_files()
156 .into_iter()
157 .find(|path| path.is_file())
158}
159
160pub fn discover_append_system_prompt_file(cwd: &Path) -> Option<PathBuf> {
175 discover_append_system_prompt_file_with_packages(
176 cwd,
177 &crate::packages::PackageResources::default(),
178 )
179}
180
181pub fn discover_append_system_prompt_file_with_packages(
183 cwd: &Path,
184 packages: &crate::packages::PackageResources,
185) -> Option<PathBuf> {
186 for project in project_config_files(cwd, "APPEND_SYSTEM.md") {
187 if project.is_file() {
188 return Some(project);
189 }
190 }
191 if let Some(global) = global_config_file("APPEND_SYSTEM.md").filter(|p| p.is_file()) {
192 return Some(global);
193 }
194 packages
195 .append_system_prompt_files()
196 .into_iter()
197 .find(|path| path.is_file())
198}
199
200pub fn dedupe_skills(skills: Vec<Skill>, diagnostics: &mut Vec<SkillDiagnostic>) -> Vec<Skill> {
214 let mut winner_path: HashMap<String, String> = HashMap::new();
215 let mut out: Vec<Skill> = Vec::with_capacity(skills.len());
216 for skill in skills {
217 if let Some(winner) = winner_path.get(&skill.name) {
218 diagnostics.push(SkillDiagnostic {
219 code: SkillDiagnosticCode::InvalidMetadata,
220 message: format!(
221 "Skill name \"{}\" from {} is shadowed by {} \
222 (first-registration wins; load project before global so project wins)",
223 skill.name, skill.file_path, winner
224 ),
225 path: skill.file_path.clone(),
226 });
227 } else {
228 winner_path.insert(skill.name.clone(), skill.file_path.clone());
229 out.push(skill);
230 }
231 }
232 out
233}
234
235pub fn dedupe_prompt_templates(
246 templates: Vec<PromptTemplate>,
247 diagnostics: &mut Vec<PromptTemplateDiagnostic>,
248) -> Vec<PromptTemplate> {
249 let mut seen: HashMap<String, ()> = HashMap::new();
250 let mut out: Vec<PromptTemplate> = Vec::with_capacity(templates.len());
251 for t in templates {
252 if seen.contains_key(&t.name) {
253 diagnostics.push(PromptTemplateDiagnostic {
254 code: PromptTemplateDiagnosticCode::ParseFailed,
255 message: format!(
256 "Prompt template name \"{}\" is shadowed by an earlier registration \
257 (first-registration wins; load project before global so project wins)",
258 t.name
259 ),
260 path: t.name.clone(),
263 });
264 } else {
265 seen.insert(t.name.clone(), ());
266 out.push(t);
267 }
268 }
269 out
270}
271
272pub fn dedupe_skills_structured(
276 skills: Vec<Skill>,
277 diagnostics: &mut Vec<rpi_harness::diagnostics::ResourceDiagnostic>,
278) -> Vec<Skill> {
279 use rpi_harness::diagnostics::{dedupe_by_name, ResourceType};
280 let keys: Vec<(String, String)> = skills
281 .iter()
282 .map(|s| (s.name.clone(), s.file_path.clone()))
283 .collect();
284 let refs: Vec<(&str, &str)> = keys.iter().map(|(n, p)| (n.as_str(), p.as_str())).collect();
285 let (winners, diags) = dedupe_by_name(ResourceType::Skill, refs);
286 diagnostics.extend(diags);
287 let winner_set: std::collections::HashSet<usize> = winners.into_iter().collect();
288 skills
289 .into_iter()
290 .enumerate()
291 .filter_map(|(index, skill)| winner_set.contains(&index).then_some(skill))
292 .collect()
293}
294
295pub fn dedupe_prompt_templates_structured(
299 templates: Vec<PromptTemplate>,
300 diagnostics: &mut Vec<rpi_harness::diagnostics::ResourceDiagnostic>,
301) -> Vec<PromptTemplate> {
302 use rpi_harness::diagnostics::{dedupe_by_name, ResourceType};
303 let keys: Vec<String> = templates.iter().map(|t| t.name.clone()).collect();
304 let refs: Vec<(&str, &str)> = keys.iter().map(|n| (n.as_str(), n.as_str())).collect();
305 let (winners, diags) = dedupe_by_name(ResourceType::Prompt, refs);
306 diagnostics.extend(diags);
307 let winner_set: std::collections::HashSet<usize> = winners.into_iter().collect();
308 templates
309 .into_iter()
310 .enumerate()
311 .filter_map(|(index, t)| winner_set.contains(&index).then_some(t))
312 .collect()
313}
314
315pub async fn load_skills_with_precedence(
324 env: &Arc<dyn ExecutionEnv>,
325 dirs: &[PathBuf],
326) -> LoadSkillsResult {
327 let dir_strs: Vec<String> = dirs
328 .iter()
329 .map(|d| d.to_string_lossy().into_owned())
330 .collect();
331 let mut result = load_skills(env, &dir_strs).await;
332 let mut structured = Vec::new();
335 let raw = std::mem::take(&mut result.skills);
336 let _ = dedupe_skills_structured(raw.clone(), &mut structured);
337 result.skills = dedupe_skills(raw, &mut result.diagnostics);
338 result.resource_diagnostics = structured;
339 result
340}
341
342pub async fn load_prompt_templates_with_precedence(
346 env: &Arc<dyn ExecutionEnv>,
347 paths: &[PathBuf],
348) -> LoadPromptTemplatesResult {
349 let path_strs: Vec<String> = paths
350 .iter()
351 .map(|p| p.to_string_lossy().into_owned())
352 .collect();
353 let mut result = load_prompt_templates(env, &path_strs).await;
354 let mut structured = Vec::new();
355 let raw = std::mem::take(&mut result.prompt_templates);
356 let _ = dedupe_prompt_templates_structured(raw.clone(), &mut structured);
357 result.prompt_templates = dedupe_prompt_templates(raw, &mut result.diagnostics);
358 result.resource_diagnostics = structured;
359 result
360}
361
362pub fn skill_dirs(cwd: &Path) -> Vec<PathBuf> {
367 let mut dirs = project_skill_dirs(cwd);
368 if let Some(g) = global_dir("skills") {
369 dirs.extend(configured_global_dirs(g.clone(), ResourceKind::Skills));
370 dirs.push(g);
371 }
372 dirs
373}
374
375pub fn project_skill_dirs(cwd: &Path) -> Vec<PathBuf> {
379 project_resource_dirs(cwd, "skills", ResourceKind::Skills)
380}
381
382pub fn global_skill_dirs() -> Vec<PathBuf> {
385 global_resource_dirs("skills", ResourceKind::Skills)
386}
387
388pub fn prompt_template_dirs(cwd: &Path) -> Vec<PathBuf> {
391 let mut dirs = project_prompt_template_dirs(cwd);
392 if let Some(g) = global_dir("prompts") {
393 dirs.extend(configured_global_dirs(g.clone(), ResourceKind::Prompts));
394 dirs.push(g);
395 }
396 dirs
397}
398
399pub fn project_prompt_template_dirs(cwd: &Path) -> Vec<PathBuf> {
401 project_resource_dirs(cwd, "prompts", ResourceKind::Prompts)
402}
403
404pub fn global_prompt_template_dirs() -> Vec<PathBuf> {
405 global_resource_dirs("prompts", ResourceKind::Prompts)
406}
407
408pub fn extension_dirs(cwd: &Path) -> Vec<PathBuf> {
412 let mut dirs = project_resource_dirs(cwd, "extensions", ResourceKind::Extensions);
413 if let Some(g) = global_dir("extensions") {
414 dirs.extend(configured_global_dirs(g.clone(), ResourceKind::Extensions));
415 dirs.push(g);
416 }
417 dirs
418}
419
420pub fn global_extension_dirs() -> Vec<PathBuf> {
421 global_resource_dirs("extensions", ResourceKind::Extensions)
422}
423
424#[derive(Clone, Copy)]
425enum ResourceKind {
426 Skills,
427 Prompts,
428 Extensions,
429}
430
431fn project_resource_dirs(cwd: &Path, sub: &str, kind: ResourceKind) -> Vec<PathBuf> {
432 let loaded = crate::settings::load_project_settings_with_paths(cwd);
433 let mut dirs = Vec::new();
434 for config_name in [".rpi", ".pi"] {
435 if let Some((_, settings)) = loaded.iter().find(|(path, _)| {
436 path.parent()
437 .and_then(Path::file_name)
438 .and_then(|name| name.to_str())
439 == Some(config_name)
440 }) {
441 dirs.extend(configured_paths(settings, cwd, kind));
442 }
443 dirs.push(cwd.join(config_name).join(sub));
444 }
445 dirs
446}
447
448fn configured_global_dirs(agent_dir: PathBuf, kind: ResourceKind) -> Vec<PathBuf> {
449 crate::settings::load_settings()
450 .ok()
451 .into_iter()
452 .flat_map(|settings| configured_paths(&settings, &agent_dir, kind))
453 .collect()
454}
455
456fn global_resource_dirs(sub: &str, kind: ResourceKind) -> Vec<PathBuf> {
457 let Some(g) = global_dir(sub) else {
458 return Vec::new();
459 };
460 let mut dirs = configured_global_dirs(g.clone(), kind);
461 dirs.push(g);
462 dirs
463}
464
465fn configured_paths(
466 settings: &crate::settings::Settings,
467 base: &Path,
468 kind: ResourceKind,
469) -> Vec<PathBuf> {
470 let values = match kind {
471 ResourceKind::Skills => settings.skill_dirs.as_ref(),
472 ResourceKind::Prompts => settings.prompt_dirs.as_ref(),
473 ResourceKind::Extensions => settings.extension_dirs.as_ref(),
474 };
475 values
476 .map(|paths| crate::settings::resolve_configured_paths(base, paths))
477 .unwrap_or_default()
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 fn skill(name: &str, path: &str) -> Skill {
485 Skill {
486 name: name.to_string(),
487 description: "d".to_string(),
488 content: "c".to_string(),
489 file_path: path.to_string(),
490 disable_model_invocation: None,
491 }
492 }
493
494 fn tmpl(name: &str) -> PromptTemplate {
495 PromptTemplate {
496 name: name.to_string(),
497 description: None,
498 content: "c".to_string(),
499 }
500 }
501
502 #[test]
503 fn dedupe_skills_first_wins_keeps_project() {
504 let skills = vec![
506 skill("echo", "/proj/.pi/skills/echo/SKILL.md"),
507 skill("echo", "/home/.rpi/agent/skills/echo/SKILL.md"),
508 ];
509 let mut diags = Vec::new();
510 let out = dedupe_skills(skills, &mut diags);
511 assert_eq!(out.len(), 1);
512 assert_eq!(out[0].file_path, "/proj/.pi/skills/echo/SKILL.md");
513 assert_eq!(diags.len(), 1);
514 assert!(diags[0]
515 .message
516 .contains("/home/.rpi/agent/skills/echo/SKILL.md"));
517 assert!(diags[0].message.contains("/proj/.pi/skills/echo/SKILL.md"));
518 assert_eq!(diags[0].path, "/home/.rpi/agent/skills/echo/SKILL.md");
519 }
520
521 #[test]
522 fn dedupe_skills_distinct_names_all_kept() {
523 let skills = vec![skill("a", "/p/a"), skill("b", "/p/b"), skill("c", "/g/c")];
524 let mut diags = Vec::new();
525 let out = dedupe_skills(skills, &mut diags);
526 assert_eq!(out.len(), 3);
527 assert!(diags.is_empty());
528 }
529
530 #[test]
531 fn dedupe_skills_third_duplicate_drops_against_first() {
532 let skills = vec![
533 skill("x", "/proj/x"),
534 skill("x", "/global/x"),
535 skill("x", "/pkg/x"),
536 ];
537 let mut diags = Vec::new();
538 let out = dedupe_skills(skills, &mut diags);
539 assert_eq!(out.len(), 1);
540 assert_eq!(out[0].file_path, "/proj/x");
541 assert_eq!(diags.len(), 2);
543 }
544
545 #[test]
546 fn dedupe_skills_empty_input() {
547 let mut diags = Vec::new();
548 let out = dedupe_skills(Vec::new(), &mut diags);
549 assert!(out.is_empty());
550 assert!(diags.is_empty());
551 }
552
553 #[test]
554 fn dedupe_prompts_first_wins() {
555 let templates = vec![tmpl("greet"), tmpl("greet")];
556 let mut diags = Vec::new();
557 let out = dedupe_prompt_templates(templates, &mut diags);
558 assert_eq!(out.len(), 1);
559 assert_eq!(out[0].name, "greet");
560 assert_eq!(diags.len(), 1);
561 assert_eq!(diags[0].path, "greet");
562 }
563
564 #[test]
565 fn dedupe_prompts_distinct_all_kept() {
566 let templates = vec![tmpl("a"), tmpl("b"), tmpl("c")];
567 let mut diags = Vec::new();
568 let out = dedupe_prompt_templates(templates, &mut diags);
569 assert_eq!(out.len(), 3);
570 assert!(diags.is_empty());
571 }
572
573 #[test]
574 fn project_dir_uses_rpi_name() {
575 let d = project_dir(Path::new("/proj"), "skills");
576 assert_eq!(d, PathBuf::from("/proj/.rpi/skills"));
577 }
578
579 #[test]
580 fn project_dirs_keep_pi_compatibility_after_rpi() {
581 let dirs = project_dirs(Path::new("/proj"), "extensions");
582 assert_eq!(
583 dirs,
584 vec![
585 PathBuf::from("/proj/.rpi/extensions"),
586 PathBuf::from("/proj/.pi/extensions")
587 ]
588 );
589 }
590
591 #[test]
592 fn project_config_file_prefers_rpi_and_keeps_pi_fallback() {
593 let p = project_config_file(Path::new("/proj"), "SYSTEM.md");
594 assert_eq!(p, PathBuf::from("/proj/.rpi/SYSTEM.md"));
595 assert_eq!(
596 project_config_files(Path::new("/proj"), "SYSTEM.md"),
597 vec![
598 PathBuf::from("/proj/.rpi/SYSTEM.md"),
599 PathBuf::from("/proj/.pi/SYSTEM.md")
600 ]
601 );
602 }
603
604 #[test]
605 fn project_settings_add_configured_resource_paths() {
606 let tmp = tempfile::tempdir().unwrap();
607 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
608 std::fs::write(
609 tmp.path().join(".rpi/settings.json"),
610 r#"{"skills":["shared-skills"],"promptDirs":["prompt-pack"],"extensionDirs":["target/debug"]}"#,
611 )
612 .unwrap();
613
614 let skills = skill_dirs(tmp.path());
615 assert_eq!(skills[0], tmp.path().join("shared-skills"));
616 assert!(skills.contains(&tmp.path().join(".rpi/skills")));
617
618 let prompts = prompt_template_dirs(tmp.path());
619 assert_eq!(prompts[0], tmp.path().join("prompt-pack"));
620
621 let extensions = extension_dirs(tmp.path());
622 assert_eq!(extensions[0], tmp.path().join("target/debug"));
623 assert!(extensions.contains(&tmp.path().join(".rpi/extensions")));
624 }
625
626 #[test]
627 fn local_resource_dirs_exclude_global_paths() {
628 let tmp = tempfile::tempdir().unwrap();
629 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
630 std::fs::write(
631 tmp.path().join(".rpi/settings.json"),
632 r#"{"skills":["local-skills"],"promptDirs":["local-prompts"]}"#,
633 )
634 .unwrap();
635
636 let skills = project_skill_dirs(tmp.path());
637 let prompts = project_prompt_template_dirs(tmp.path());
638 assert!(skills.contains(&tmp.path().join("local-skills")));
639 assert!(skills.contains(&tmp.path().join(".rpi/skills")));
640 assert!(prompts.contains(&tmp.path().join("local-prompts")));
641 assert!(prompts.contains(&tmp.path().join(".rpi/prompts")));
642 }
643}