rpi_cli/resource_dirs.rs
1//! Resource-directory resolution + **project-wins dedupe** for skills and
2//! prompt templates. Mirrors the precedence contract from pi's
3//! `DefaultResourceLoader`/`package-manager` (`resource-loader.ts:676-681`,
4//! `package-manager.ts:178-181`): resources are ranked project=0/1 < user=2/3
5//! < package=4, and `addSkills`/`dedupePrompts` are **first-registration-wins**
6//! on name → loading project *before* global means **project wins** on
7//! collision (skills.ts:399-428), with a collision diagnostic naming the winner
8//! (kept) and loser (dropped) paths.
9//!
10//! rpi's library loaders (`rpi_harness::skills::load_skills`,
11//! `rpi_harness::prompt_templates::load_prompt_templates`) are **append-only**
12//! (no dedup-by-name) — they correctly mirror pi's *per-directory* discovery
13//! (SKILL.md-first / root-.md / subdir recursion for skills; non-recursive `.md`
14//! children for prompts) but leave the cross-directory merge to the caller. This
15//! module is that caller-side merge: load project dir then global dir, then
16//! dedupe first-wins-by-name so project wins.
17//!
18//! **Trust gate (v1 divergence):** pi gates project `.pi/SYSTEM.md` /
19//! `.pi/APPEND_SYSTEM.md` (and some project resources) behind
20//! `settingsManager.isProjectTrusted()`. rpi v1 has **no trust prompt**
21//! (`config.rs:349`: "does not gate any project resources behind trust in v1"),
22//! so project resources are read unconditionally here. A copied `.pi/` directory
23//! drops in and works (the documented intent). Full trust gating is deferred.
24//!
25//! **Deferred (documented):** pi's `.agents/skills` + `~/.agents/skills` +
26//! package-installed skills/prompts (4 discovery roots in pi; rpi v1 mirrors the
27//! two primary: project `.pi/<sub>` + user `agent_dir()<sub>`); worktree
28//! shadowed-context-file dedup (`findShadowedContextFile`); full structured
29//! winner/loser collision diagnostics (rpi v1 encodes collisions as a
30//! `SkillDiagnostic`/`PromptTemplateDiagnostic` with a descriptive message).
31
32use 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
44/// The project-local config dir name. Mirrors pi's `.pi/` (NOT `.rpi/`) so a
45/// copied pi project directory drops in and works: skills under `<cwd>/.pi/skills`,
46/// prompts under `<cwd>/.pi/prompts`, `SYSTEM.md`/`APPEND_SYSTEM.md` under
47/// `<cwd>/.pi/`. The **global** config lives under `agent_dir()` (`~/.rpi/agent`),
48/// which IS `.rpi` — see `config.rs`.
49pub const PROJECT_CONFIG_DIR_NAME: &str = ".pi";
50
51/// Resolve the project-local resource subdir `<cwd>/.pi/<sub>`.
52pub fn project_dir(cwd: &Path, sub: &str) -> PathBuf {
53 cwd.join(PROJECT_CONFIG_DIR_NAME).join(sub)
54}
55
56/// Resolve the global resource subdir `<agent_dir>/<sub>` (e.g.
57/// `~/.rpi/agent/skills`). Returns `None` if the agent dir can't be resolved
58/// (no home dir + no `RPI_CODING_AGENT_DIR`) — callers then proceed project-only.
59pub fn global_dir(sub: &str) -> Option<PathBuf> {
60 crate::config::agent_dir().ok().map(|d| d.join(sub))
61}
62
63/// The candidate context/SYSTEM/APPEND filenames live directly under
64/// `<cwd>/.pi/` and `<agent_dir>/` (no `skills`/`prompts` subdir). Re-exports the
65/// harness context-file candidates for the system/append discovery path so
66/// callers share one source of truth.
67pub fn project_config_file(cwd: &Path, name: &str) -> PathBuf {
68 cwd.join(PROJECT_CONFIG_DIR_NAME).join(name)
69}
70
71/// Global config file under `<agent_dir>/<name>` (`~/.rpi/agent/SYSTEM.md`).
72pub fn global_config_file(name: &str) -> Option<PathBuf> {
73 crate::config::agent_dir().ok().map(|d| d.join(name))
74}
75
76// ---------------------------------------------------------------------------
77// SYSTEM.md / APPEND_SYSTEM.md discovery (project-wins, mirroring pi)
78// ---------------------------------------------------------------------------
79
80/// Discover `SYSTEM.md`: project `<cwd>/.pi/SYSTEM.md` overrides global
81/// `<agent_dir>/SYSTEM.md` (mirrors pi `discoverSystemPromptFile`
82/// `resource-loader.ts:1022-1034`). Returns the first existing file in that
83/// order, or `None`.
84///
85/// **Trust gate (v1 divergence):** pi gates the **project** `SYSTEM.md` behind
86/// `settingsManager.isProjectTrusted()` (global is always honored). rpi v1 has
87/// no trust prompt (`config.rs:349`), so the project file is read unconditionally
88/// — a copied `.pi/` drops in and works. Full trust gating is deferred.
89pub fn discover_system_prompt_file(cwd: &Path) -> Option<PathBuf> {
90 let project = project_config_file(cwd, "SYSTEM.md");
91 if project.is_file() {
92 return Some(project);
93 }
94 global_config_file("SYSTEM.md").filter(|p| p.is_file())
95}
96
97/// Discover `APPEND_SYSTEM.md`: same precedence as `SYSTEM.md` — project
98/// `<cwd>/.pi/APPEND_SYSTEM.md` overrides global `<agent_dir>/APPEND_SYSTEM.md`
99/// (mirrors pi `discoverAppendSystemPromptFile` `resource-loader.ts:1036-1048`).
100/// Returns the first existing file in that order, or `None`. The discovered
101/// content is appended to the system prompt (pi `appendSystemPrompt`
102/// `:525-542`).
103///
104/// **Trust gate (v1 divergence):** same as [`discover_system_prompt_file`] —
105/// pi gates the project file on trust, rpi v1 reads it unconditionally.
106pub fn discover_append_system_prompt_file(cwd: &Path) -> Option<PathBuf> {
107 let project = project_config_file(cwd, "APPEND_SYSTEM.md");
108 if project.is_file() {
109 return Some(project);
110 }
111 global_config_file("APPEND_SYSTEM.md").filter(|p| p.is_file())
112}
113
114// ---------------------------------------------------------------------------
115// Dedupe: first-wins-by-name (project wins when loaded project→global)
116// ---------------------------------------------------------------------------
117
118/// Dedupe skills by name, **first-wins**. Mirrors pi `addSkills`
119/// (`skills.ts:399-428`): the first skill with a given name is kept; later
120/// duplicates emit a collision diagnostic naming the winner (kept) and loser
121/// (dropped) paths. Load dirs in **project→global** order so project wins.
122///
123/// **v1 divergence:** rpi's `SkillDiagnostic` has no structured
124/// `winnerPath`/`loserPath` fields (pi's `SkillCollisionDiagnostic`); the
125/// collision is encoded as an `InvalidMetadata` diagnostic with a descriptive
126/// message naming both paths and `path` set to the loser.
127pub fn dedupe_skills(skills: Vec<Skill>, diagnostics: &mut Vec<SkillDiagnostic>) -> Vec<Skill> {
128 let mut winner_path: HashMap<String, String> = HashMap::new();
129 let mut out: Vec<Skill> = Vec::with_capacity(skills.len());
130 for skill in skills {
131 if let Some(winner) = winner_path.get(&skill.name) {
132 diagnostics.push(SkillDiagnostic {
133 code: SkillDiagnosticCode::InvalidMetadata,
134 message: format!(
135 "Skill name \"{}\" from {} is shadowed by {} \
136 (first-registration wins; load project before global so project wins)",
137 skill.name, skill.file_path, winner
138 ),
139 path: skill.file_path.clone(),
140 });
141 } else {
142 winner_path.insert(skill.name.clone(), skill.file_path.clone());
143 out.push(skill);
144 }
145 }
146 out
147}
148
149/// Dedupe prompt templates by name, **first-wins**. Mirrors pi `dedupePrompts`
150/// (`resource-loader.ts:969-993`): the first template with a given name is kept;
151/// later duplicates emit a collision diagnostic. Load paths in **project→global**
152/// order so project wins.
153///
154/// **v1 divergence:** `PromptTemplate` carries no `file_path` (only
155/// name/description/content), so the collision diagnostic's `path` is set to the
156/// colliding template **name** rather than a file path; and the code is
157/// `ParseFailed` (rpi has no dedicated collision code) with a descriptive
158/// message. Structured winner/loser diagnostics are deferred.
159pub fn dedupe_prompt_templates(
160 templates: Vec<PromptTemplate>,
161 diagnostics: &mut Vec<PromptTemplateDiagnostic>,
162) -> Vec<PromptTemplate> {
163 let mut seen: HashMap<String, ()> = HashMap::new();
164 let mut out: Vec<PromptTemplate> = Vec::with_capacity(templates.len());
165 for t in templates {
166 if seen.contains_key(&t.name) {
167 diagnostics.push(PromptTemplateDiagnostic {
168 code: PromptTemplateDiagnosticCode::ParseFailed,
169 message: format!(
170 "Prompt template name \"{}\" is shadowed by an earlier registration \
171 (first-registration wins; load project before global so project wins)",
172 t.name
173 ),
174 // PromptTemplate carries no file path; the name is the collision
175 // key, so use it as the diagnostic path.
176 path: t.name.clone(),
177 });
178 } else {
179 seen.insert(t.name.clone(), ());
180 out.push(t);
181 }
182 }
183 out
184}
185
186// ---------------------------------------------------------------------------
187// Precedence-aware loaders: project dir → global dir → dedupe (project wins)
188// ---------------------------------------------------------------------------
189
190/// Load skills from `dirs` in order, then dedupe first-wins-by-name. Missing
191/// directories are skipped silently by the underlying loader (NotFound →
192/// `continue`, mirroring pi). Pass dirs in **project→global** order so project
193/// wins on name collisions.
194pub async fn load_skills_with_precedence(
195 env: &Arc<dyn ExecutionEnv>,
196 dirs: &[PathBuf],
197) -> LoadSkillsResult {
198 let dir_strs: Vec<String> = dirs
199 .iter()
200 .map(|d| d.to_string_lossy().into_owned())
201 .collect();
202 let mut result = load_skills(env, &dir_strs).await;
203 result.skills = dedupe_skills(result.skills, &mut result.diagnostics);
204 result
205}
206
207/// Load prompt templates from `paths` (dirs or `.md` files) in order, then
208/// dedupe first-wins-by-name. Missing paths are skipped silently. Pass paths in
209/// **project→global** order so project wins on name collisions.
210pub async fn load_prompt_templates_with_precedence(
211 env: &Arc<dyn ExecutionEnv>,
212 paths: &[PathBuf],
213) -> LoadPromptTemplatesResult {
214 let path_strs: Vec<String> = paths
215 .iter()
216 .map(|p| p.to_string_lossy().into_owned())
217 .collect();
218 let mut result = load_prompt_templates(env, &path_strs).await;
219 result.prompt_templates =
220 dedupe_prompt_templates(result.prompt_templates, &mut result.diagnostics);
221 result
222}
223
224/// The ordered skill dirs for a project: `[<cwd>/.pi/skills, <agent_dir>/skills]`.
225/// The global dir is omitted when `agent_dir()` can't be resolved (no home dir).
226pub fn skill_dirs(cwd: &Path) -> Vec<PathBuf> {
227 let mut dirs = vec![project_dir(cwd, "skills")];
228 if let Some(g) = global_dir("skills") {
229 dirs.push(g);
230 }
231 dirs
232}
233
234/// The ordered prompt-template paths for a project:
235/// `[<cwd>/.pi/prompts, <agent_dir>/prompts]`.
236pub fn prompt_template_dirs(cwd: &Path) -> Vec<PathBuf> {
237 let mut dirs = vec![project_dir(cwd, "prompts")];
238 if let Some(g) = global_dir("prompts") {
239 dirs.push(g);
240 }
241 dirs
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 fn skill(name: &str, path: &str) -> Skill {
249 Skill {
250 name: name.to_string(),
251 description: "d".to_string(),
252 content: "c".to_string(),
253 file_path: path.to_string(),
254 disable_model_invocation: None,
255 }
256 }
257
258 fn tmpl(name: &str) -> PromptTemplate {
259 PromptTemplate {
260 name: name.to_string(),
261 description: None,
262 content: "c".to_string(),
263 }
264 }
265
266 #[test]
267 fn dedupe_skills_first_wins_keeps_project() {
268 // Project loaded first, global second; same name → project (first) wins.
269 let skills = vec![
270 skill("echo", "/proj/.pi/skills/echo/SKILL.md"),
271 skill("echo", "/home/.rpi/agent/skills/echo/SKILL.md"),
272 ];
273 let mut diags = Vec::new();
274 let out = dedupe_skills(skills, &mut diags);
275 assert_eq!(out.len(), 1);
276 assert_eq!(out[0].file_path, "/proj/.pi/skills/echo/SKILL.md");
277 assert_eq!(diags.len(), 1);
278 assert!(diags[0]
279 .message
280 .contains("/home/.rpi/agent/skills/echo/SKILL.md"));
281 assert!(diags[0].message.contains("/proj/.pi/skills/echo/SKILL.md"));
282 assert_eq!(diags[0].path, "/home/.rpi/agent/skills/echo/SKILL.md");
283 }
284
285 #[test]
286 fn dedupe_skills_distinct_names_all_kept() {
287 let skills = vec![skill("a", "/p/a"), skill("b", "/p/b"), skill("c", "/g/c")];
288 let mut diags = Vec::new();
289 let out = dedupe_skills(skills, &mut diags);
290 assert_eq!(out.len(), 3);
291 assert!(diags.is_empty());
292 }
293
294 #[test]
295 fn dedupe_skills_third_duplicate_drops_against_first() {
296 let skills = vec![
297 skill("x", "/proj/x"),
298 skill("x", "/global/x"),
299 skill("x", "/pkg/x"),
300 ];
301 let mut diags = Vec::new();
302 let out = dedupe_skills(skills, &mut diags);
303 assert_eq!(out.len(), 1);
304 assert_eq!(out[0].file_path, "/proj/x");
305 // Both later duplicates emit a collision diagnostic vs the same winner.
306 assert_eq!(diags.len(), 2);
307 }
308
309 #[test]
310 fn dedupe_skills_empty_input() {
311 let mut diags = Vec::new();
312 let out = dedupe_skills(Vec::new(), &mut diags);
313 assert!(out.is_empty());
314 assert!(diags.is_empty());
315 }
316
317 #[test]
318 fn dedupe_prompts_first_wins() {
319 let templates = vec![tmpl("greet"), tmpl("greet")];
320 let mut diags = Vec::new();
321 let out = dedupe_prompt_templates(templates, &mut diags);
322 assert_eq!(out.len(), 1);
323 assert_eq!(out[0].name, "greet");
324 assert_eq!(diags.len(), 1);
325 assert_eq!(diags[0].path, "greet");
326 }
327
328 #[test]
329 fn dedupe_prompts_distinct_all_kept() {
330 let templates = vec![tmpl("a"), tmpl("b"), tmpl("c")];
331 let mut diags = Vec::new();
332 let out = dedupe_prompt_templates(templates, &mut diags);
333 assert_eq!(out.len(), 3);
334 assert!(diags.is_empty());
335 }
336
337 #[test]
338 fn project_dir_uses_pi_name() {
339 let d = project_dir(Path::new("/proj"), "skills");
340 assert_eq!(d, PathBuf::from("/proj/.pi/skills"));
341 }
342
343 #[test]
344 fn project_config_file_under_pi() {
345 let p = project_config_file(Path::new("/proj"), "SYSTEM.md");
346 assert_eq!(p, PathBuf::from("/proj/.pi/SYSTEM.md"));
347 }
348}