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/` or
23//! `.pi/` directory drops in and works (the documented intent). Full trust
24//! gating is deferred.
25//!
26//! **Deferred (documented):** pi's `.agents/skills` + `~/.agents/skills` (rpi
27//! package-installed static resources are handled by `crate::packages`); 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 preferred project-local config dir. rpi-owned resources live under
45/// `<cwd>/.rpi`; the legacy `.pi` directory remains a compatibility fallback.
46pub const PROJECT_CONFIG_DIR_NAME: &str = ".rpi";
47/// The upstream Pi project-local config dir, loaded after `.rpi`.
48pub const LEGACY_PROJECT_CONFIG_DIR_NAME: &str = ".pi";
49
50/// Resolve the preferred project-local resource subdir `<cwd>/.rpi/<sub>`.
51pub fn project_dir(cwd: &Path, sub: &str) -> PathBuf {
52    cwd.join(PROJECT_CONFIG_DIR_NAME).join(sub)
53}
54
55/// Resolve project-local resource dirs in precedence order: `.rpi` first,
56/// then the original Pi `.pi` layout for compatibility.
57pub 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
64/// Resolve the global resource subdir `<agent_dir>/<sub>` (e.g.
65/// `~/.rpi/agent/skills`). Returns `None` if the agent dir can't be resolved
66/// (no home dir + no `RPI_CODING_AGENT_DIR`) — callers then proceed project-only.
67pub fn global_dir(sub: &str) -> Option<PathBuf> {
68    crate::config::agent_dir().ok().map(|d| d.join(sub))
69}
70
71/// The candidate context/SYSTEM/APPEND filenames live directly under
72/// `<cwd>/.rpi/`, `<cwd>/.pi/`, and `<agent_dir>/` (no `skills`/`prompts`
73/// subdir). Re-exports the
74/// harness context-file candidates for the system/append discovery path so
75/// callers share one source of truth.
76pub fn project_config_file(cwd: &Path, name: &str) -> PathBuf {
77    cwd.join(PROJECT_CONFIG_DIR_NAME).join(name)
78}
79
80/// Resolve project config files in precedence order: preferred `.rpi`, then
81/// legacy `.pi`.
82pub 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
89/// Global config file under `<agent_dir>/<name>` (`~/.rpi/agent/SYSTEM.md`).
90pub fn global_config_file(name: &str) -> Option<PathBuf> {
91    crate::config::agent_dir().ok().map(|d| d.join(name))
92}
93
94// ---------------------------------------------------------------------------
95// SYSTEM.md / APPEND_SYSTEM.md discovery (project-wins, mirroring pi)
96// ---------------------------------------------------------------------------
97
98/// Discover `SYSTEM.md`: project `<cwd>/.rpi/SYSTEM.md` overrides the legacy
99/// `<cwd>/.pi/SYSTEM.md`, and both override global `<agent_dir>/SYSTEM.md`
100/// (mirrors pi `discoverSystemPromptFile`
101/// `resource-loader.ts:1022-1034`). Returns the first existing file in that
102/// order, or `None`.
103///
104/// This convenience entry point intentionally excludes Pi package resources.
105/// Startup code that has passed the package gate supplies its resolved resource
106/// set to [`discover_system_prompt_file_with_packages`].
107///
108/// **Trust gate:** When `project_trusted` is `false`, project-level files are
109/// skipped and only global files are considered. This prevents untrusted
110/// projects from injecting system prompts.
111pub fn discover_system_prompt_file(cwd: &Path) -> Option<PathBuf> {
112    discover_system_prompt_file_with_trust(cwd, true, &crate::packages::PackageResources::default())
113}
114
115/// Discover `SYSTEM.md` with explicit trust control.
116///
117/// When `project_trusted` is `false`, project-level files are skipped.
118pub 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
139/// Discover `SYSTEM.md` with already-resolved package resources. Package files
140/// are considered after project and global files, so installing a package
141/// cannot unexpectedly override a user's project instructions.
142pub 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
160/// Discover `APPEND_SYSTEM.md`: same precedence as `SYSTEM.md` — project
161/// `<cwd>/.rpi/APPEND_SYSTEM.md` overrides `<cwd>/.pi/APPEND_SYSTEM.md`, and
162/// both override global `<agent_dir>/APPEND_SYSTEM.md`
163/// (mirrors pi `discoverAppendSystemPromptFile` `resource-loader.ts:1036-1048`).
164/// Returns the first existing file in that order, or `None`. The discovered
165/// content is appended to the system prompt (pi `appendSystemPrompt`
166/// `:525-542`).
167///
168/// This convenience entry point intentionally excludes Pi package resources.
169/// Startup code that has passed the package gate supplies its resolved resource
170/// set to [`discover_append_system_prompt_file_with_packages`].
171///
172/// **Trust gate (v1 divergence):** same as [`discover_system_prompt_file`] —
173/// pi gates the project file on trust, rpi v1 reads it unconditionally.
174pub 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
181/// Discover `APPEND_SYSTEM.md` with already-resolved package resources.
182pub 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
200// ---------------------------------------------------------------------------
201// Dedupe: first-wins-by-name (project wins when loaded project→global)
202// ---------------------------------------------------------------------------
203
204/// Dedupe skills by name, **first-wins**. Mirrors pi `addSkills`
205/// (`skills.ts:399-428`): the first skill with a given name is kept; later
206/// duplicates emit a collision diagnostic naming the winner (kept) and loser
207/// (dropped) paths. Load dirs in **project→global** order so project wins.
208///
209/// **v1 divergence:** rpi's `SkillDiagnostic` has no structured
210/// `winnerPath`/`loserPath` fields (pi's `SkillCollisionDiagnostic`); the
211/// collision is encoded as an `InvalidMetadata` diagnostic with a descriptive
212/// message naming both paths and `path` set to the loser.
213pub 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
235/// Dedupe prompt templates by name, **first-wins**. Mirrors pi `dedupePrompts`
236/// (`resource-loader.ts:969-993`): the first template with a given name is kept;
237/// later duplicates emit a collision diagnostic. Load paths in **project→global**
238/// order so project wins.
239///
240/// **v1 divergence:** `PromptTemplate` carries no `file_path` (only
241/// name/description/content), so the collision diagnostic's `path` is set to the
242/// colliding template **name** rather than a file path; and the code is
243/// `ParseFailed` (rpi has no dedicated collision code) with a descriptive
244/// message. Structured winner/loser diagnostics are deferred.
245pub 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                // PromptTemplate carries no file path; the name is the collision
261                // key, so use it as the diagnostic path.
262                path: t.name.clone(),
263            });
264        } else {
265            seen.insert(t.name.clone(), ());
266            out.push(t);
267        }
268    }
269    out
270}
271
272/// Structured variant of [`dedupe_skills`]: emits a [`ResourceDiagnostic`] per
273/// shadowed skill (winner/loser path) instead of a stringly-typed diagnostic.
274/// Callers that want to surface collisions in the UI (e.g. `/reload`) use this.
275pub 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
295/// Structured variant of [`dedupe_prompt_templates`]. `PromptTemplate` carries
296/// no file path, so the collision loser/winner paths fall back to the template
297/// name (documented divergence).
298pub 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
315// ---------------------------------------------------------------------------
316// Precedence-aware loaders: project dir → global dir → dedupe (project wins)
317// ---------------------------------------------------------------------------
318
319/// Load skills from `dirs` in order, then dedupe first-wins-by-name. Missing
320/// directories are skipped silently by the underlying loader (NotFound →
321/// `continue`, mirroring pi). Pass dirs in **project→global** order so project
322/// wins on name collisions.
323pub 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    // Collect structured collisions from the pre-dedupe list so the UI can
333    // show winner/loser paths (native `ResourceDiagnostic`), then dedupe.
334    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
342/// Load prompt templates from `paths` (dirs or `.md` files) in order, then
343/// dedupe first-wins-by-name. Missing paths are skipped silently. Pass paths in
344/// **project→global** order so project wins on name collisions.
345pub 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
362/// The ordered skill dirs for a project: configured project paths, then
363/// `[<cwd>/.rpi/skills, <cwd>/.pi/skills, <agent_dir>/skills]`, followed by
364/// configured and conventional global paths.
365/// The global dir is omitted when `agent_dir()` can't be resolved (no home dir).
366pub 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
375/// Project-only skill directories, excluding user/global configuration. This
376/// is used by `rpi dev-local` so the current extension can be debugged with
377/// the skills in the current checkout without importing unrelated skills.
378pub fn project_skill_dirs(cwd: &Path) -> Vec<PathBuf> {
379    project_resource_dirs(cwd, "skills", ResourceKind::Skills)
380}
381
382/// Resource directories that do not depend on the current project. Used when
383/// the project trust gate denies local configuration/resources.
384pub fn global_skill_dirs() -> Vec<PathBuf> {
385    global_resource_dirs("skills", ResourceKind::Skills)
386}
387
388/// The ordered prompt-template paths for a project: configured paths, then
389/// `[<cwd>/.rpi/prompts, <cwd>/.pi/prompts, <agent_dir>/prompts]`.
390pub 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
399/// Project-only prompt-template directories, excluding user/global settings.
400pub 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
408/// The ordered Rust extension directories for a project. Configured paths are
409/// added before conventional directories so an explicit project path can be
410/// used for development while `.rpi` remains ahead of legacy `.pi` defaults.
411pub 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        // Project loaded first, global second; same name → project (first) wins.
505        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        // Both later duplicates emit a collision diagnostic vs the same winner.
542        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}