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