Skip to main content

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 `SYSTEM.md` /
19//! `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 `.rpi/`
23//! directory drops in and works (the documented intent). Full trust gating is
24//! deferred.
25//!
26//! **Deferred (documented):** `.agents/skills` + `~/.agents/skills`;
27//! shadowed-context-file dedup (`findShadowedContextFile`); full structured
28//! winner/loser collision diagnostics (rpi v1 encodes collisions as a
29//! `SkillDiagnostic`/`PromptTemplateDiagnostic` with a descriptive message).
30
31use 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
43/// The project-local rpi configuration directory.
44pub const PROJECT_CONFIG_DIR_NAME: &str = ".rpi";
45
46/// Resolve the preferred project-local resource subdir `<cwd>/.rpi/<sub>`.
47pub fn project_dir(cwd: &Path, sub: &str) -> PathBuf {
48    cwd.join(PROJECT_CONFIG_DIR_NAME).join(sub)
49}
50
51/// Resolve project-local resource dirs. Only `.rpi` is read.
52pub fn project_dirs(cwd: &Path, sub: &str) -> Vec<PathBuf> {
53    vec![project_dir(cwd, 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>/.rpi/` and `<agent_dir>/`.
65pub fn project_config_file(cwd: &Path, name: &str) -> PathBuf {
66    cwd.join(PROJECT_CONFIG_DIR_NAME).join(name)
67}
68
69/// Resolve project config files. Only `.rpi` is read.
70pub fn project_config_files(cwd: &Path, name: &str) -> Vec<PathBuf> {
71    vec![project_config_file(cwd, name)]
72}
73
74/// Global config file under `<agent_dir>/<name>` (`~/.rpi/agent/SYSTEM.md`).
75pub fn global_config_file(name: &str) -> Option<PathBuf> {
76    crate::config::agent_dir().ok().map(|d| d.join(name))
77}
78
79/// Discover `SYSTEM.md` from the project `.rpi/` directory or global agent dir.
80pub fn discover_system_prompt_file(cwd: &Path) -> Option<PathBuf> {
81    discover_system_prompt_file_with_trust(cwd, true)
82}
83
84/// Discover `SYSTEM.md` with explicit project trust control.
85pub 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
99/// Discover `APPEND_SYSTEM.md` using project-over-global precedence.
100pub 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
109// ---------------------------------------------------------------------------
110// Dedupe: first-wins-by-name (project wins when loaded project→global)
111// ---------------------------------------------------------------------------
112
113/// Dedupe skills by name, **first-wins**. Mirrors pi `addSkills`
114/// (`skills.ts:399-428`): the first skill with a given name is kept; later
115/// duplicates emit a collision diagnostic naming the winner (kept) and loser
116/// (dropped) paths. Load dirs in **project→global** order so project wins.
117///
118/// **v1 divergence:** rpi's `SkillDiagnostic` has no structured
119/// `winnerPath`/`loserPath` fields (pi's `SkillCollisionDiagnostic`); the
120/// collision is encoded as an `InvalidMetadata` diagnostic with a descriptive
121/// message naming both paths and `path` set to the loser.
122pub 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
144/// Dedupe prompt templates by name, **first-wins**. Mirrors pi `dedupePrompts`
145/// (`resource-loader.ts:969-993`): the first template with a given name is kept;
146/// later duplicates emit a collision diagnostic. Load paths in **project→global**
147/// order so project wins.
148///
149/// **v1 divergence:** `PromptTemplate` carries no `file_path` (only
150/// name/description/content), so the collision diagnostic's `path` is set to the
151/// colliding template **name** rather than a file path; and the code is
152/// `ParseFailed` (rpi has no dedicated collision code) with a descriptive
153/// message. Structured winner/loser diagnostics are deferred.
154pub 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                // PromptTemplate carries no file path; the name is the collision
170                // key, so use it as the diagnostic path.
171                path: t.name.clone(),
172            });
173        } else {
174            seen.insert(t.name.clone(), ());
175            out.push(t);
176        }
177    }
178    out
179}
180
181/// Structured variant of [`dedupe_skills`]: emits a [`ResourceDiagnostic`] per
182/// shadowed skill (winner/loser path) instead of a stringly-typed diagnostic.
183/// Callers that want to surface collisions in the UI (e.g. `/reload`) use this.
184pub 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
204/// Structured variant of [`dedupe_prompt_templates`]. `PromptTemplate` carries
205/// no file path, so the collision loser/winner paths fall back to the template
206/// name (documented divergence).
207pub 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
224// ---------------------------------------------------------------------------
225// Precedence-aware loaders: project dir → global dir → dedupe (project wins)
226// ---------------------------------------------------------------------------
227
228/// Load skills from `dirs` in order, then dedupe first-wins-by-name. Missing
229/// directories are skipped silently by the underlying loader (NotFound →
230/// `continue`, mirroring pi). Pass dirs in **project→global** order so project
231/// wins on name collisions.
232pub 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    // Collect structured collisions from the pre-dedupe list so the UI can
242    // show winner/loser paths (native `ResourceDiagnostic`), then dedupe.
243    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
251/// Load prompt templates from `paths` (dirs or `.md` files) in order, then
252/// dedupe first-wins-by-name. Missing paths are skipped silently. Pass paths in
253/// **project→global** order so project wins on name collisions.
254pub 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
271/// The ordered skill dirs for a project: configured project paths, then
272/// `[<cwd>/.rpi/skills, <agent_dir>/skills]`, followed by
273/// configured and conventional global paths.
274/// The global dir is omitted when `agent_dir()` can't be resolved (no home dir).
275pub 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
284/// Project-only skill directories, excluding user/global configuration. This
285/// is used by `rpi dev-local` so the current extension can be debugged with
286/// the skills in the current checkout without importing unrelated skills.
287pub fn project_skill_dirs(cwd: &Path) -> Vec<PathBuf> {
288    project_resource_dirs(cwd, "skills", ResourceKind::Skills)
289}
290
291/// Resource directories that do not depend on the current project. Used when
292/// the project trust gate denies local configuration/resources.
293pub fn global_skill_dirs() -> Vec<PathBuf> {
294    global_resource_dirs("skills", ResourceKind::Skills)
295}
296
297/// The ordered prompt-template paths for a project: configured paths, then
298/// `[<cwd>/.rpi/prompts, <agent_dir>/prompts]`.
299pub 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
308/// Project-only prompt-template directories, excluding user/global settings.
309pub 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
317/// The ordered Rust extension directories for a project. Configured paths are
318/// added before conventional directories so an explicit project path can be
319/// used for development while `.rpi` defaults stay lower precedence.
320pub 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    // Project packages are explicit, local resources. Keep configured project
351    // directories first, then package resources, then the conventional
352    // `.rpi/<sub>` directory so existing precedence remains stable.
353    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        // Project loaded first, global second; same name → project (first) wins.
420        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        // Both later duplicates emit a collision diagnostic vs the same winner.
457        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}