1use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34
35use rpi_harness::prompt_templates::{
36 load_prompt_templates, LoadPromptTemplatesResult, PromptTemplateDiagnostic,
37 PromptTemplateDiagnosticCode,
38};
39use rpi_harness::skills::{load_skills, LoadSkillsResult, SkillDiagnostic, SkillDiagnosticCode};
40use rpi_harness::types::{PromptTemplate, Skill};
41use rpi_tools::env::ExecutionEnv;
42
43pub const PROJECT_CONFIG_DIR_NAME: &str = ".rpi";
45
46pub fn project_dir(cwd: &Path, sub: &str) -> PathBuf {
48 cwd.join(PROJECT_CONFIG_DIR_NAME).join(sub)
49}
50
51pub fn project_dirs(cwd: &Path, sub: &str) -> Vec<PathBuf> {
53 vec![project_dir(cwd, sub)]
54}
55
56pub fn global_dir(sub: &str) -> Option<PathBuf> {
60 crate::config::agent_dir().ok().map(|d| d.join(sub))
61}
62
63pub fn project_config_file(cwd: &Path, name: &str) -> PathBuf {
66 cwd.join(PROJECT_CONFIG_DIR_NAME).join(name)
67}
68
69pub fn project_config_files(cwd: &Path, name: &str) -> Vec<PathBuf> {
71 vec![project_config_file(cwd, name)]
72}
73
74pub fn global_config_file(name: &str) -> Option<PathBuf> {
76 crate::config::agent_dir().ok().map(|d| d.join(name))
77}
78
79pub fn discover_system_prompt_file(cwd: &Path) -> Option<PathBuf> {
81 discover_system_prompt_file_with_trust(cwd, true)
82}
83
84pub fn discover_system_prompt_file_with_trust(
86 cwd: &Path,
87 project_trusted: bool,
88) -> Option<PathBuf> {
89 if project_trusted {
90 for project in project_config_files(cwd, "SYSTEM.md") {
91 if project.is_file() {
92 return Some(project);
93 }
94 }
95 }
96 global_config_file("SYSTEM.md").filter(|p| p.is_file())
97}
98
99pub fn discover_append_system_prompt_file(cwd: &Path) -> Option<PathBuf> {
101 for project in project_config_files(cwd, "APPEND_SYSTEM.md") {
102 if project.is_file() {
103 return Some(project);
104 }
105 }
106 global_config_file("APPEND_SYSTEM.md").filter(|p| p.is_file())
107}
108
109pub fn dedupe_skills(skills: Vec<Skill>, diagnostics: &mut Vec<SkillDiagnostic>) -> Vec<Skill> {
123 let mut winner_path: HashMap<String, String> = HashMap::new();
124 let mut out: Vec<Skill> = Vec::with_capacity(skills.len());
125 for skill in skills {
126 if let Some(winner) = winner_path.get(&skill.name) {
127 diagnostics.push(SkillDiagnostic {
128 code: SkillDiagnosticCode::InvalidMetadata,
129 message: format!(
130 "Skill name \"{}\" from {} is shadowed by {} \
131 (first-registration wins; load project before global so project wins)",
132 skill.name, skill.file_path, winner
133 ),
134 path: skill.file_path.clone(),
135 });
136 } else {
137 winner_path.insert(skill.name.clone(), skill.file_path.clone());
138 out.push(skill);
139 }
140 }
141 out
142}
143
144pub fn dedupe_prompt_templates(
155 templates: Vec<PromptTemplate>,
156 diagnostics: &mut Vec<PromptTemplateDiagnostic>,
157) -> Vec<PromptTemplate> {
158 let mut seen: HashMap<String, ()> = HashMap::new();
159 let mut out: Vec<PromptTemplate> = Vec::with_capacity(templates.len());
160 for t in templates {
161 if seen.contains_key(&t.name) {
162 diagnostics.push(PromptTemplateDiagnostic {
163 code: PromptTemplateDiagnosticCode::ParseFailed,
164 message: format!(
165 "Prompt template name \"{}\" is shadowed by an earlier registration \
166 (first-registration wins; load project before global so project wins)",
167 t.name
168 ),
169 path: t.name.clone(),
172 });
173 } else {
174 seen.insert(t.name.clone(), ());
175 out.push(t);
176 }
177 }
178 out
179}
180
181pub fn dedupe_skills_structured(
185 skills: Vec<Skill>,
186 diagnostics: &mut Vec<rpi_harness::diagnostics::ResourceDiagnostic>,
187) -> Vec<Skill> {
188 use rpi_harness::diagnostics::{dedupe_by_name, ResourceType};
189 let keys: Vec<(String, String)> = skills
190 .iter()
191 .map(|s| (s.name.clone(), s.file_path.clone()))
192 .collect();
193 let refs: Vec<(&str, &str)> = keys.iter().map(|(n, p)| (n.as_str(), p.as_str())).collect();
194 let (winners, diags) = dedupe_by_name(ResourceType::Skill, refs);
195 diagnostics.extend(diags);
196 let winner_set: std::collections::HashSet<usize> = winners.into_iter().collect();
197 skills
198 .into_iter()
199 .enumerate()
200 .filter_map(|(index, skill)| winner_set.contains(&index).then_some(skill))
201 .collect()
202}
203
204pub fn dedupe_prompt_templates_structured(
208 templates: Vec<PromptTemplate>,
209 diagnostics: &mut Vec<rpi_harness::diagnostics::ResourceDiagnostic>,
210) -> Vec<PromptTemplate> {
211 use rpi_harness::diagnostics::{dedupe_by_name, ResourceType};
212 let keys: Vec<String> = templates.iter().map(|t| t.name.clone()).collect();
213 let refs: Vec<(&str, &str)> = keys.iter().map(|n| (n.as_str(), n.as_str())).collect();
214 let (winners, diags) = dedupe_by_name(ResourceType::Prompt, refs);
215 diagnostics.extend(diags);
216 let winner_set: std::collections::HashSet<usize> = winners.into_iter().collect();
217 templates
218 .into_iter()
219 .enumerate()
220 .filter_map(|(index, t)| winner_set.contains(&index).then_some(t))
221 .collect()
222}
223
224pub async fn load_skills_with_precedence(
233 env: &Arc<dyn ExecutionEnv>,
234 dirs: &[PathBuf],
235) -> LoadSkillsResult {
236 let dir_strs: Vec<String> = dirs
237 .iter()
238 .map(|d| d.to_string_lossy().into_owned())
239 .collect();
240 let mut result = load_skills(env, &dir_strs).await;
241 let mut structured = Vec::new();
244 let raw = std::mem::take(&mut result.skills);
245 let _ = dedupe_skills_structured(raw.clone(), &mut structured);
246 result.skills = dedupe_skills(raw, &mut result.diagnostics);
247 result.resource_diagnostics = structured;
248 result
249}
250
251pub async fn load_prompt_templates_with_precedence(
255 env: &Arc<dyn ExecutionEnv>,
256 paths: &[PathBuf],
257) -> LoadPromptTemplatesResult {
258 let path_strs: Vec<String> = paths
259 .iter()
260 .map(|p| p.to_string_lossy().into_owned())
261 .collect();
262 let mut result = load_prompt_templates(env, &path_strs).await;
263 let mut structured = Vec::new();
264 let raw = std::mem::take(&mut result.prompt_templates);
265 let _ = dedupe_prompt_templates_structured(raw.clone(), &mut structured);
266 result.prompt_templates = dedupe_prompt_templates(raw, &mut result.diagnostics);
267 result.resource_diagnostics = structured;
268 result
269}
270
271pub fn skill_dirs(cwd: &Path) -> Vec<PathBuf> {
276 let mut dirs = project_skill_dirs(cwd);
277 if let Some(g) = global_dir("skills") {
278 dirs.extend(configured_global_dirs(g.clone(), ResourceKind::Skills));
279 dirs.push(g);
280 }
281 dirs
282}
283
284pub fn project_skill_dirs(cwd: &Path) -> Vec<PathBuf> {
288 project_resource_dirs(cwd, "skills", ResourceKind::Skills)
289}
290
291pub fn global_skill_dirs() -> Vec<PathBuf> {
294 global_resource_dirs("skills", ResourceKind::Skills)
295}
296
297pub fn prompt_template_dirs(cwd: &Path) -> Vec<PathBuf> {
300 let mut dirs = project_prompt_template_dirs(cwd);
301 if let Some(g) = global_dir("prompts") {
302 dirs.extend(configured_global_dirs(g.clone(), ResourceKind::Prompts));
303 dirs.push(g);
304 }
305 dirs
306}
307
308pub fn project_prompt_template_dirs(cwd: &Path) -> Vec<PathBuf> {
310 project_resource_dirs(cwd, "prompts", ResourceKind::Prompts)
311}
312
313pub fn global_prompt_template_dirs() -> Vec<PathBuf> {
314 global_resource_dirs("prompts", ResourceKind::Prompts)
315}
316
317pub fn extension_dirs(cwd: &Path) -> Vec<PathBuf> {
321 let mut dirs = project_resource_dirs(cwd, "extensions", ResourceKind::Extensions);
322 if let Some(g) = global_dir("extensions") {
323 dirs.extend(configured_global_dirs(g.clone(), ResourceKind::Extensions));
324 dirs.push(g);
325 }
326 dirs
327}
328
329pub fn project_extension_dirs(cwd: &Path) -> Vec<PathBuf> {
330 project_resource_dirs(cwd, "extensions", ResourceKind::Extensions)
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 (_, settings) in loaded.iter() {
348 dirs.extend(configured_paths(settings, cwd, kind));
349 }
350 let package_kind = match kind {
354 ResourceKind::Skills => "skills",
355 ResourceKind::Prompts => "prompts",
356 ResourceKind::Extensions => "extensions",
357 };
358 dirs.extend(crate::packages::project_package_dirs(cwd, package_kind));
359 dirs.push(project_dir(cwd, sub));
360 dirs
361}
362
363fn configured_global_dirs(agent_dir: PathBuf, kind: ResourceKind) -> Vec<PathBuf> {
364 crate::settings::load_settings()
365 .ok()
366 .into_iter()
367 .flat_map(|settings| configured_paths(&settings, &agent_dir, kind))
368 .collect()
369}
370
371fn global_resource_dirs(sub: &str, kind: ResourceKind) -> Vec<PathBuf> {
372 let Some(g) = global_dir(sub) else {
373 return Vec::new();
374 };
375 let mut dirs = configured_global_dirs(g.clone(), kind);
376 dirs.push(g);
377 dirs
378}
379
380fn configured_paths(
381 settings: &crate::settings::Settings,
382 base: &Path,
383 kind: ResourceKind,
384) -> Vec<PathBuf> {
385 let values = match kind {
386 ResourceKind::Skills => settings.skill_dirs.as_ref(),
387 ResourceKind::Prompts => settings.prompt_dirs.as_ref(),
388 ResourceKind::Extensions => settings.extension_dirs.as_ref(),
389 };
390 values
391 .map(|paths| crate::settings::resolve_configured_paths(base, paths))
392 .unwrap_or_default()
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn skill(name: &str, path: &str) -> Skill {
400 Skill {
401 name: name.to_string(),
402 description: "d".to_string(),
403 content: "c".to_string(),
404 file_path: path.to_string(),
405 disable_model_invocation: None,
406 }
407 }
408
409 fn tmpl(name: &str) -> PromptTemplate {
410 PromptTemplate {
411 name: name.to_string(),
412 description: None,
413 content: "c".to_string(),
414 }
415 }
416
417 #[test]
418 fn dedupe_skills_first_wins_keeps_project() {
419 let skills = vec![
421 skill("echo", "/proj/.rpi/skills/echo/SKILL.md"),
422 skill("echo", "/home/.rpi/agent/skills/echo/SKILL.md"),
423 ];
424 let mut diags = Vec::new();
425 let out = dedupe_skills(skills, &mut diags);
426 assert_eq!(out.len(), 1);
427 assert_eq!(out[0].file_path, "/proj/.rpi/skills/echo/SKILL.md");
428 assert_eq!(diags.len(), 1);
429 assert!(diags[0]
430 .message
431 .contains("/home/.rpi/agent/skills/echo/SKILL.md"));
432 assert!(diags[0].message.contains("/proj/.rpi/skills/echo/SKILL.md"));
433 assert_eq!(diags[0].path, "/home/.rpi/agent/skills/echo/SKILL.md");
434 }
435
436 #[test]
437 fn dedupe_skills_distinct_names_all_kept() {
438 let skills = vec![skill("a", "/p/a"), skill("b", "/p/b"), skill("c", "/g/c")];
439 let mut diags = Vec::new();
440 let out = dedupe_skills(skills, &mut diags);
441 assert_eq!(out.len(), 3);
442 assert!(diags.is_empty());
443 }
444
445 #[test]
446 fn dedupe_skills_third_duplicate_drops_against_first() {
447 let skills = vec![
448 skill("x", "/proj/x"),
449 skill("x", "/global/x"),
450 skill("x", "/pkg/x"),
451 ];
452 let mut diags = Vec::new();
453 let out = dedupe_skills(skills, &mut diags);
454 assert_eq!(out.len(), 1);
455 assert_eq!(out[0].file_path, "/proj/x");
456 assert_eq!(diags.len(), 2);
458 }
459
460 #[test]
461 fn dedupe_skills_empty_input() {
462 let mut diags = Vec::new();
463 let out = dedupe_skills(Vec::new(), &mut diags);
464 assert!(out.is_empty());
465 assert!(diags.is_empty());
466 }
467
468 #[test]
469 fn dedupe_prompts_first_wins() {
470 let templates = vec![tmpl("greet"), tmpl("greet")];
471 let mut diags = Vec::new();
472 let out = dedupe_prompt_templates(templates, &mut diags);
473 assert_eq!(out.len(), 1);
474 assert_eq!(out[0].name, "greet");
475 assert_eq!(diags.len(), 1);
476 assert_eq!(diags[0].path, "greet");
477 }
478
479 #[test]
480 fn dedupe_prompts_distinct_all_kept() {
481 let templates = vec![tmpl("a"), tmpl("b"), tmpl("c")];
482 let mut diags = Vec::new();
483 let out = dedupe_prompt_templates(templates, &mut diags);
484 assert_eq!(out.len(), 3);
485 assert!(diags.is_empty());
486 }
487
488 #[test]
489 fn project_dir_uses_rpi_name() {
490 let d = project_dir(Path::new("/proj"), "skills");
491 assert_eq!(d, PathBuf::from("/proj/.rpi/skills"));
492 }
493
494 #[test]
495 fn project_dirs_only_use_rpi_after_de_pi() {
496 let dirs = project_dirs(Path::new("/proj"), "extensions");
497 assert_eq!(dirs, vec![PathBuf::from("/proj/.rpi/extensions")]);
498 }
499
500 #[test]
501 fn project_config_files_only_use_rpi_after_de_pi() {
502 let p = project_config_file(Path::new("/proj"), "SYSTEM.md");
503 assert_eq!(p, PathBuf::from("/proj/.rpi/SYSTEM.md"));
504 assert_eq!(
505 project_config_files(Path::new("/proj"), "SYSTEM.md"),
506 vec![p]
507 );
508 }
509
510 #[test]
511 fn project_settings_add_configured_resource_paths() {
512 let tmp = tempfile::tempdir().unwrap();
513 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
514 std::fs::write(
515 tmp.path().join(".rpi/settings.json"),
516 r#"{"skills":["shared-skills"],"promptDirs":["prompt-pack"],"extensionDirs":["target/debug"]}"#,
517 )
518 .unwrap();
519
520 let skills = skill_dirs(tmp.path());
521 assert_eq!(skills[0], tmp.path().join("shared-skills"));
522 assert!(skills.contains(&tmp.path().join(".rpi/skills")));
523
524 let prompts = prompt_template_dirs(tmp.path());
525 assert_eq!(prompts[0], tmp.path().join("prompt-pack"));
526
527 let extensions = extension_dirs(tmp.path());
528 assert_eq!(extensions[0], tmp.path().join("target/debug"));
529 assert!(extensions.contains(&tmp.path().join(".rpi/extensions")));
530 }
531
532 #[test]
533 fn local_resource_dirs_exclude_global_paths() {
534 let tmp = tempfile::tempdir().unwrap();
535 std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
536 std::fs::write(
537 tmp.path().join(".rpi/settings.json"),
538 r#"{"skills":["local-skills"],"promptDirs":["local-prompts"]}"#,
539 )
540 .unwrap();
541
542 let skills = project_skill_dirs(tmp.path());
543 let prompts = project_prompt_template_dirs(tmp.path());
544 assert!(skills.contains(&tmp.path().join("local-skills")));
545 assert!(skills.contains(&tmp.path().join(".rpi/skills")));
546 assert!(prompts.contains(&tmp.path().join("local-prompts")));
547 assert!(prompts.contains(&tmp.path().join(".rpi/prompts")));
548 }
549}