Skip to main content

oino_resources/
loader.rs

1//! [`ResourceLoader`] (minus extension loading): skills, prompt
2//! templates, theme files, context files and SYSTEM/APPEND prompt sources.
3
4use std::collections::HashSet;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use indexmap::IndexMap;
9use oino_settings::Settings;
10use oino_settings::paths::{
11    APPEND_SYSTEM_PROMPT_FILE, SYSTEM_PROMPT_FILE, canonicalize_or_self, is_under_path,
12    lexical_normalize, project_dir, resolve_path,
13};
14use serde::{Deserialize, Serialize};
15
16use crate::context_files::{ContextFile, load_project_context_files};
17use crate::diagnostics::{CollisionResource, ResourceDiagnostic};
18use crate::discovery::{DiscoveryInput, ResolvedResource, is_local_path, resolve_local_resources};
19use crate::skills::{LoadSkillsOptions, Skill, load_skills};
20use crate::source::{PathMetadata, SourceInfo, SourceOrigin, SourceScope};
21use crate::templates::{LoadPromptTemplatesOptions, PromptTemplate, load_prompt_templates};
22use crate::walk::{has_suffix, read_dir_sorted};
23
24/// Construction inputs (runtime subset).
25#[allow(
26    clippy::struct_excessive_bools,
27    reason = "mirrors the CLI flags -ns, -np, --no-themes, -nc"
28)]
29#[derive(Clone, Debug, Default, PartialEq, Eq)]
30pub struct ResourceLoaderOptions {
31    /// Working directory (made absolute).
32    pub cwd: PathBuf,
33    /// Agent dir (made absolute).
34    pub agent_dir: PathBuf,
35    /// Home dir for `~` and `~/.agents/skills` (`None`: neither is used).
36    pub home_dir: Option<PathBuf>,
37    /// `-ns`: skip discovered and configured skills (CLI `--skill` paths still load).
38    pub no_skills: bool,
39    /// `-np`: skip discovered and configured prompt templates.
40    pub no_prompt_templates: bool,
41    /// Skip discovered and configured themes.
42    pub no_themes: bool,
43    /// `-nc`: skip context files.
44    pub no_context_files: bool,
45    /// `--skill` paths (relative to cwd).
46    pub skill_paths: Vec<String>,
47    /// `--prompt-template` paths.
48    pub prompt_template_paths: Vec<String>,
49    /// `--theme` paths.
50    pub theme_paths: Vec<String>,
51    /// `--system-prompt` text or file path.
52    pub system_prompt: Option<String>,
53    /// `--append-system-prompt` values (text or file paths, in order). `Some` replaces the
54    /// discovered `APPEND_SYSTEM.md`; `None` discovers it.
55    pub append_system_prompt: Option<Vec<String>>,
56}
57
58/// Paths contributed after load (packages and
59/// `resources_discover`).
60#[derive(Clone, Debug, Default, PartialEq, Eq)]
61pub struct ResourceExtensionPaths {
62    /// Skill files or dirs.
63    pub skill_paths: Vec<ExtraResourcePath>,
64    /// Prompt template files or dirs.
65    pub prompt_paths: Vec<ExtraResourcePath>,
66    /// Theme files or dirs.
67    pub theme_paths: Vec<ExtraResourcePath>,
68}
69
70/// One contributed path with its provenance.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct ExtraResourcePath {
73    /// Path (relative to cwd when not absolute).
74    pub path: PathBuf,
75    /// Provenance metadata.
76    pub metadata: PathMetadata,
77}
78
79/// A discovered theme file (frontends parse and render themes).
80#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct ThemeResource {
83    /// Theme `name` (`unnamed` when absent).
84    pub name: String,
85    /// Theme JSON file.
86    pub path: PathBuf,
87    /// Provenance.
88    pub source_info: SourceInfo,
89}
90
91/// Loads every markdown/JSON resource for one cwd; call [`Self::reload`] after construction and
92/// whenever settings or trust change. Synchronous (run it on a blocking thread).
93#[derive(Clone, Debug, Default)]
94pub struct ResourceLoader {
95    options: ResourceLoaderOptions,
96    project_trusted: bool,
97    metadata: IndexMap<PathBuf, PathMetadata>,
98    extra_skill_infos: IndexMap<PathBuf, SourceInfo>,
99    extra_prompt_infos: IndexMap<PathBuf, SourceInfo>,
100    extra_theme_infos: IndexMap<PathBuf, SourceInfo>,
101    skill_paths: Vec<PathBuf>,
102    prompt_paths: Vec<PathBuf>,
103    theme_paths: Vec<PathBuf>,
104    skills: Vec<Skill>,
105    skill_diagnostics: Vec<ResourceDiagnostic>,
106    prompts: Vec<PromptTemplate>,
107    prompt_diagnostics: Vec<ResourceDiagnostic>,
108    themes: Vec<ThemeResource>,
109    theme_diagnostics: Vec<ResourceDiagnostic>,
110    context_files: Vec<ContextFile>,
111    context_diagnostics: Vec<ResourceDiagnostic>,
112    system_prompt: Option<String>,
113    system_prompt_source: Option<PathBuf>,
114    append_system_prompt: Vec<String>,
115    append_system_prompt_sources: Vec<PathBuf>,
116    prompt_input_diagnostics: Vec<ResourceDiagnostic>,
117}
118
119impl ResourceLoader {
120    /// A loader with nothing loaded yet.
121    #[must_use]
122    pub fn new(options: ResourceLoaderOptions) -> Self {
123        let absolute = |path: &Path| {
124            std::path::absolute(path).map_or_else(|_| path.to_path_buf(), |p| lexical_normalize(&p))
125        };
126        let options = ResourceLoaderOptions {
127            cwd: absolute(&options.cwd),
128            agent_dir: absolute(&options.agent_dir),
129            ..options
130        };
131        Self {
132            options,
133            ..Self::default()
134        }
135    }
136
137    /// The (absolutised) options.
138    #[must_use]
139    pub fn options(&self) -> &ResourceLoaderOptions {
140        &self.options
141    }
142
143    /// Re-resolve everything for `settings` and the trust state (`reload`; extension
144    /// contributions from [`Self::extend`] are dropped).
145    pub fn reload(&mut self, settings: &Settings, project_trusted: bool) {
146        self.project_trusted = project_trusted;
147        let cwd = self.options.cwd.clone();
148        let agent_dir = self.options.agent_dir.clone();
149        let home = self.options.home_dir.clone();
150        let resolved = resolve_local_resources(&DiscoveryInput {
151            cwd: &cwd,
152            agent_dir: &agent_dir,
153            home: home.as_deref(),
154            project_trusted,
155            settings,
156        });
157        self.metadata = IndexMap::new();
158        self.extra_skill_infos = IndexMap::new();
159        self.extra_prompt_infos = IndexMap::new();
160        self.extra_theme_infos = IndexMap::new();
161        let enabled_skills: Vec<PathBuf> = self
162            .enabled(&resolved.skills)
163            .into_iter()
164            .map(|resource| self.map_skill_path(&resource))
165            .collect();
166        let enabled_prompts: Vec<PathBuf> = self
167            .enabled(&resolved.prompts)
168            .into_iter()
169            .map(|r| r.path)
170            .collect();
171        let enabled_themes: Vec<PathBuf> = self
172            .enabled(&resolved.themes)
173            .into_iter()
174            .map(|r| r.path)
175            .collect();
176
177        let cli_skills = to_paths(&self.options.skill_paths);
178        let cli_prompts = to_paths(&self.options.prompt_template_paths);
179        let cli_themes = to_paths(&self.options.theme_paths);
180
181        self.skill_paths = if self.options.no_skills {
182            self.merge_paths(&[], &cli_skills)
183        } else {
184            self.merge_paths(&enabled_skills, &cli_skills)
185        };
186        self.update_skills();
187        for path in &self.options.skill_paths.clone() {
188            if is_local_path(path) {
189                let resolved = self.resolve_resource_path(Path::new(path));
190                if !resolved.exists() && !has_diagnostic_for(&self.skill_diagnostics, &resolved) {
191                    self.skill_diagnostics.push(ResourceDiagnostic::error(
192                        "Skill path does not exist",
193                        resolved,
194                    ));
195                }
196            }
197        }
198
199        self.prompt_paths = if self.options.no_prompt_templates {
200            self.merge_paths(&[], &cli_prompts)
201        } else {
202            self.merge_paths(&enabled_prompts, &cli_prompts)
203        };
204        self.update_prompts();
205        for path in &self.options.prompt_template_paths.clone() {
206            if is_local_path(path) {
207                let resolved = self.resolve_resource_path(Path::new(path));
208                if !resolved.exists() && !has_diagnostic_for(&self.prompt_diagnostics, &resolved) {
209                    self.prompt_diagnostics.push(ResourceDiagnostic::error(
210                        "Prompt template path does not exist",
211                        resolved,
212                    ));
213                }
214            }
215        }
216
217        self.theme_paths = if self.options.no_themes {
218            self.merge_paths(&[], &cli_themes)
219        } else {
220            self.merge_paths(&enabled_themes, &cli_themes)
221        };
222        self.update_themes();
223        for path in &self.options.theme_paths.clone() {
224            let resolved = self.resolve_resource_path(Path::new(path));
225            if !resolved.exists() && !has_diagnostic_for(&self.theme_diagnostics, &resolved) {
226                self.theme_diagnostics.push(ResourceDiagnostic::error(
227                    "Theme path does not exist",
228                    resolved,
229                ));
230            }
231        }
232
233        if self.options.no_context_files {
234            self.context_files = Vec::new();
235            self.context_diagnostics = Vec::new();
236        } else {
237            let loaded = load_project_context_files(&cwd, &agent_dir);
238            self.context_files = loaded.files;
239            self.context_diagnostics = loaded.diagnostics;
240        }
241
242        self.load_prompt_sources();
243    }
244
245    /// Add package / `resources_discover` paths.
246    pub fn extend(&mut self, paths: &ResourceExtensionPaths) {
247        let normalize =
248            |this: &Self, entries: &[ExtraResourcePath]| -> Vec<(PathBuf, SourceInfo)> {
249                entries
250                    .iter()
251                    .map(|entry| {
252                        let path = this.resolve_resource_path(&entry.path);
253                        let mut metadata = entry.metadata.clone();
254                        metadata.base_dir = metadata
255                            .base_dir
256                            .map(|base| this.resolve_resource_path(&base));
257                        let info = SourceInfo::from_metadata(&path, &metadata);
258                        (path, info)
259                    })
260                    .collect()
261            };
262        let skills = normalize(self, &paths.skill_paths);
263        let prompts = normalize(self, &paths.prompt_paths);
264        let themes = normalize(self, &paths.theme_paths);
265        for (path, info) in &skills {
266            self.extra_skill_infos.insert(path.clone(), info.clone());
267        }
268        for (path, info) in &prompts {
269            self.extra_prompt_infos.insert(path.clone(), info.clone());
270        }
271        for (path, info) in &themes {
272            self.extra_theme_infos.insert(path.clone(), info.clone());
273        }
274        if !skills.is_empty() {
275            let added: Vec<PathBuf> = skills.into_iter().map(|(path, _)| path).collect();
276            self.skill_paths = self.merge_paths(&self.skill_paths.clone(), &added);
277            self.update_skills();
278        }
279        if !prompts.is_empty() {
280            let added: Vec<PathBuf> = prompts.into_iter().map(|(path, _)| path).collect();
281            self.prompt_paths = self.merge_paths(&self.prompt_paths.clone(), &added);
282            self.update_prompts();
283        }
284        if !themes.is_empty() {
285            let added: Vec<PathBuf> = themes.into_iter().map(|(path, _)| path).collect();
286            self.theme_paths = self.merge_paths(&self.theme_paths.clone(), &added);
287            self.update_themes();
288        }
289    }
290
291    /// Loaded skills (first-wins by name).
292    #[must_use]
293    pub fn skills(&self) -> &[Skill] {
294        &self.skills
295    }
296
297    /// Skill diagnostics.
298    #[must_use]
299    pub fn skill_diagnostics(&self) -> &[ResourceDiagnostic] {
300        &self.skill_diagnostics
301    }
302
303    /// Loaded prompt templates (first-wins by name).
304    #[must_use]
305    pub fn prompts(&self) -> &[PromptTemplate] {
306        &self.prompts
307    }
308
309    /// Prompt template diagnostics.
310    #[must_use]
311    pub fn prompt_diagnostics(&self) -> &[ResourceDiagnostic] {
312        &self.prompt_diagnostics
313    }
314
315    /// Theme files (first-wins by name).
316    #[must_use]
317    pub fn themes(&self) -> &[ThemeResource] {
318        &self.themes
319    }
320
321    /// Theme diagnostics.
322    #[must_use]
323    pub fn theme_diagnostics(&self) -> &[ResourceDiagnostic] {
324        &self.theme_diagnostics
325    }
326
327    /// Context files (empty with `-nc`).
328    #[must_use]
329    pub fn context_files(&self) -> &[ContextFile] {
330        &self.context_files
331    }
332
333    /// The system prompt override (CLI, trusted `.oino/SYSTEM.md`, `<agent>/SYSTEM.md`).
334    #[must_use]
335    pub fn system_prompt(&self) -> Option<&str> {
336        self.system_prompt.as_deref()
337    }
338
339    /// File the system prompt came from, if any.
340    #[must_use]
341    pub fn system_prompt_source(&self) -> Option<&Path> {
342        self.system_prompt_source.as_deref()
343    }
344
345    /// Append texts in order (joined with `\n\n` into the `addendum` section).
346    #[must_use]
347    pub fn append_system_prompt(&self) -> &[String] {
348        &self.append_system_prompt
349    }
350
351    /// The `addendum` text: [`Self::append_system_prompt`] joined by `\n\n`.
352    #[must_use]
353    pub fn addendum(&self) -> String {
354        self.append_system_prompt.join("\n\n")
355    }
356
357    /// Files the append texts came from.
358    #[must_use]
359    pub fn append_system_prompt_sources(&self) -> &[PathBuf] {
360        &self.append_system_prompt_sources
361    }
362
363    /// Every diagnostic: skills, prompts, themes, context files, prompt sources.
364    #[must_use]
365    pub fn diagnostics(&self) -> Vec<ResourceDiagnostic> {
366        [
367            &self.skill_diagnostics,
368            &self.prompt_diagnostics,
369            &self.theme_diagnostics,
370            &self.context_diagnostics,
371            &self.prompt_input_diagnostics,
372        ]
373        .into_iter()
374        .flatten()
375        .cloned()
376        .collect()
377    }
378
379    fn enabled(&mut self, resources: &[ResolvedResource]) -> Vec<ResolvedResource> {
380        for resource in resources {
381            self.metadata
382                .entry(resource.path.clone())
383                .or_insert_with(|| resource.metadata.clone());
384        }
385        resources.iter().filter(|r| r.enabled).cloned().collect()
386    }
387
388    /// An auto/package skill dir with `SKILL.md` maps to that file.
389    fn map_skill_path(&mut self, resource: &ResolvedResource) -> PathBuf {
390        if resource.metadata.source != "auto" && resource.metadata.origin != SourceOrigin::Package {
391            return resource.path.clone();
392        }
393        if !resource.path.is_dir() {
394            return resource.path.clone();
395        }
396        let skill_file = resource.path.join("SKILL.md");
397        if skill_file.exists() {
398            self.metadata
399                .entry(skill_file.clone())
400                .or_insert_with(|| resource.metadata.clone());
401            return skill_file;
402        }
403        resource.path.clone()
404    }
405
406    fn resolve_resource_path(&self, path: &Path) -> PathBuf {
407        resolve_path(
408            &path.to_string_lossy(),
409            &self.options.cwd,
410            self.options.home_dir.as_deref(),
411            true,
412        )
413    }
414
415    /// Resolve, dedupe by canonical path, keep first.
416    fn merge_paths(&self, primary: &[PathBuf], additional: &[PathBuf]) -> Vec<PathBuf> {
417        let mut seen = HashSet::new();
418        primary
419            .iter()
420            .chain(additional)
421            .map(|path| self.resolve_resource_path(path))
422            .filter(|path| seen.insert(canonicalize_or_self(path)))
423            .collect()
424    }
425
426    fn update_skills(&mut self) {
427        if self.options.no_skills && self.skill_paths.is_empty() {
428            self.skills = Vec::new();
429            self.skill_diagnostics = Vec::new();
430            return;
431        }
432        let result = load_skills(&LoadSkillsOptions {
433            cwd: &self.options.cwd,
434            agent_dir: &self.options.agent_dir,
435            home: self.options.home_dir.as_deref(),
436            skill_paths: &self.skill_paths,
437            include_defaults: false,
438        });
439        self.skills = result
440            .skills
441            .into_iter()
442            .map(|mut skill| {
443                if let Some(info) = self.find_source_info(&skill.file_path, &self.extra_skill_infos)
444                {
445                    skill.source_info = info;
446                }
447                skill
448            })
449            .collect();
450        self.skill_diagnostics = result.diagnostics;
451    }
452
453    fn update_prompts(&mut self) {
454        if self.options.no_prompt_templates && self.prompt_paths.is_empty() {
455            self.prompts = Vec::new();
456            self.prompt_diagnostics = Vec::new();
457            return;
458        }
459        let loaded = load_prompt_templates(&LoadPromptTemplatesOptions {
460            cwd: &self.options.cwd,
461            agent_dir: &self.options.agent_dir,
462            home: self.options.home_dir.as_deref(),
463            prompt_paths: &self.prompt_paths,
464            include_defaults: false,
465        });
466        let mut diagnostics = loaded.diagnostics;
467        let mut by_name: IndexMap<String, PromptTemplate> = IndexMap::new();
468        for prompt in loaded.templates {
469            if let Some(existing) = by_name.get(&prompt.name) {
470                diagnostics.push(ResourceDiagnostic::collision(
471                    format!("name \"/{}\" collision", prompt.name),
472                    CollisionResource::Prompt,
473                    &prompt.name,
474                    &existing.file_path,
475                    &prompt.file_path,
476                ));
477            } else {
478                by_name.insert(prompt.name.clone(), prompt);
479            }
480        }
481        self.prompts = by_name
482            .into_values()
483            .map(|mut prompt| {
484                if let Some(info) =
485                    self.find_source_info(&prompt.file_path, &self.extra_prompt_infos)
486                {
487                    prompt.source_info = info;
488                }
489                prompt
490            })
491            .collect();
492        self.prompt_diagnostics = diagnostics;
493    }
494
495    fn update_themes(&mut self) {
496        if self.options.no_themes && self.theme_paths.is_empty() {
497            self.themes = Vec::new();
498            self.theme_diagnostics = Vec::new();
499            return;
500        }
501        let mut files = Vec::new();
502        let mut diagnostics = Vec::new();
503        for path in &self.theme_paths {
504            let Ok(metadata) = fs::metadata(path) else {
505                diagnostics.push(ResourceDiagnostic::warning(
506                    "theme path does not exist",
507                    path,
508                ));
509                continue;
510            };
511            if metadata.is_dir() {
512                for entry in read_dir_sorted(path).unwrap_or_default() {
513                    if entry.is_file && has_suffix(&entry.name, ".json") {
514                        files.push(entry.path);
515                    }
516                }
517            } else if metadata.is_file() && has_suffix(&path.to_string_lossy(), ".json") {
518                files.push(path.clone());
519            } else {
520                diagnostics.push(ResourceDiagnostic::warning(
521                    "theme path is not a json file",
522                    path,
523                ));
524            }
525        }
526        let mut by_name: IndexMap<String, ThemeResource> = IndexMap::new();
527        for file in files {
528            let name = match theme_name(&file) {
529                Ok(name) => name,
530                Err(message) => {
531                    diagnostics.push(ResourceDiagnostic::warning(message, &file));
532                    continue;
533                }
534            };
535            if let Some(existing) = by_name.get(&name) {
536                diagnostics.push(ResourceDiagnostic::collision(
537                    format!("name \"{name}\" collision"),
538                    CollisionResource::Theme,
539                    &name,
540                    &existing.path,
541                    &file,
542                ));
543                continue;
544            }
545            let source_info = self
546                .find_source_info(&file, &self.extra_theme_infos)
547                .unwrap_or_else(|| self.default_source_info(&file));
548            by_name.insert(
549                name.clone(),
550                ThemeResource {
551                    name,
552                    path: file,
553                    source_info,
554                },
555            );
556        }
557        self.themes = by_name.into_values().collect();
558        self.theme_diagnostics = diagnostics;
559    }
560
561    /// Contributed paths first, then exact, then ancestor metadata.
562    fn find_source_info(
563        &self,
564        path: &Path,
565        extra: &IndexMap<PathBuf, SourceInfo>,
566    ) -> Option<SourceInfo> {
567        let normalized = lexical_normalize(path);
568        if let Some(info) = extra
569            .iter()
570            .find(|(source, _)| normalized.starts_with(lexical_normalize(source)))
571            .map(|(_, info)| info)
572        {
573            return Some(SourceInfo {
574                path: path.to_path_buf(),
575                ..info.clone()
576            });
577        }
578        if let Some(metadata) = self
579            .metadata
580            .get(&normalized)
581            .or_else(|| self.metadata.get(path))
582        {
583            return Some(SourceInfo::from_metadata(path, metadata));
584        }
585        self.metadata
586            .iter()
587            .find(|(source, _)| normalized.starts_with(lexical_normalize(source)))
588            .map(|(_, metadata)| SourceInfo::from_metadata(path, metadata))
589    }
590
591    /// Returns the default source info for path.
592    fn default_source_info(&self, path: &Path) -> SourceInfo {
593        let normalized = lexical_normalize(path);
594        let kinds = ["skills", "prompts", "themes", "extensions"];
595        let project = project_dir(&self.options.cwd);
596        for (base, scope) in [
597            (&self.options.agent_dir, SourceScope::User),
598            (&project, SourceScope::Project),
599        ] {
600            if let Some(root) = kinds
601                .iter()
602                .map(|kind| base.join(kind))
603                .find(|root| is_under_path(&normalized, root))
604            {
605                return SourceInfo::synthetic(path, "local", Some(scope), Some(root));
606            }
607        }
608        let base = if normalized.is_dir() {
609            normalized.clone()
610        } else {
611            normalized
612                .parent()
613                .map_or_else(|| normalized.clone(), Path::to_path_buf)
614        };
615        SourceInfo::synthetic(path, "local", None, Some(base))
616    }
617
618    fn load_prompt_sources(&mut self) {
619        self.prompt_input_diagnostics = Vec::new();
620        let project = project_dir(&self.options.cwd);
621        let discover = |name: &str, trusted: bool, agent_dir: &Path| -> Option<String> {
622            let project_file = project.join(name);
623            if trusted && project_file.exists() {
624                return Some(project_file.to_string_lossy().into_owned());
625            }
626            let global = agent_dir.join(name);
627            global
628                .exists()
629                .then(|| global.to_string_lossy().into_owned())
630        };
631        let source = self.options.system_prompt.clone().or_else(|| {
632            discover(
633                SYSTEM_PROMPT_FILE,
634                self.project_trusted,
635                &self.options.agent_dir,
636            )
637        });
638        self.system_prompt = source
639            .as_deref()
640            .and_then(|input| self.resolve_prompt_input(input, "system prompt"));
641        self.system_prompt_source = source
642            .as_deref()
643            .and_then(|input| self.existing_path(input));
644
645        let append_sources = self
646            .options
647            .append_system_prompt
648            .clone()
649            .unwrap_or_else(|| {
650                discover(
651                    APPEND_SYSTEM_PROMPT_FILE,
652                    self.project_trusted,
653                    &self.options.agent_dir,
654                )
655                .into_iter()
656                .collect()
657            });
658        self.append_system_prompt = append_sources
659            .iter()
660            .filter_map(|input| self.resolve_prompt_input(input, "append system prompt"))
661            .collect();
662        self.append_system_prompt_sources = append_sources
663            .iter()
664            .filter_map(|input| self.existing_path(input))
665            .collect();
666    }
667
668    fn existing_path(&self, input: &str) -> Option<PathBuf> {
669        let path = resolve_path(input, &self.options.cwd, None, false);
670        path.exists().then_some(path)
671    }
672
673    /// File content when `input` names an existing file, else the text.
674    fn resolve_prompt_input(&mut self, input: &str, description: &str) -> Option<String> {
675        if input.is_empty() {
676            return None;
677        }
678        let Some(path) = self.existing_path(input) else {
679            return Some(input.to_owned());
680        };
681        match fs::read_to_string(&path) {
682            Ok(content) => Some(
683                content
684                    .strip_prefix('\u{feff}')
685                    .map_or(content.clone(), str::to_owned),
686            ),
687            Err(error) => {
688                self.prompt_input_diagnostics
689                    .push(ResourceDiagnostic::warning(
690                        format!("Could not read {description} file {input}: {error}"),
691                        &path,
692                    ));
693                Some(input.to_owned())
694            }
695        }
696    }
697}
698
699fn to_paths(values: &[String]) -> Vec<PathBuf> {
700    values.iter().map(PathBuf::from).collect()
701}
702
703fn has_diagnostic_for(diagnostics: &[ResourceDiagnostic], path: &Path) -> bool {
704    diagnostics
705        .iter()
706        .any(|diagnostic| diagnostic.path.as_deref() == Some(path))
707}
708
709/// The `name` of a theme file (the full schema is validated in the TUI).
710fn theme_name(path: &Path) -> Result<String, String> {
711    let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
712    let value: serde_json::Value = serde_json::from_str(oino_settings::json::strip_bom(&content))
713        .map_err(|e| e.to_string())?;
714    let object = value
715        .as_object()
716        .ok_or_else(|| "theme must be a JSON object".to_owned())?;
717    Ok(object
718        .get("name")
719        .and_then(serde_json::Value::as_str)
720        .unwrap_or("unnamed")
721        .to_owned())
722}