prompter/config.rs
1//! Configuration types and config file loading.
2use crate::{
3 PrompterError, collect_profiles, library_dir, load_config_bundle, resolve_config_path,
4};
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8/// A profile definition carrying declared dependencies and its library root.
9#[derive(Debug, Clone)]
10pub struct ProfileDef {
11 /// Declared dependencies: either profile names or `.md` paths.
12 pub(crate) deps: Vec<String>,
13 /// Absolute path to the library root this profile's `.md` deps resolve against.
14 pub(crate) library_root: PathBuf,
15}
16
17/// Configuration structure holding merged profile definitions and primary post-prompt.
18///
19/// Profiles map names to [`ProfileDef`] values, where each profile carries the
20/// library root it was loaded against. After a bundle load, profiles from all
21/// imported configs are flattened into this one map; `post_prompt` only
22/// reflects the *primary* config (imports' `post_prompt` values are ignored).
23#[derive(Debug)]
24pub struct Config {
25 /// Map of profile names to their definitions.
26 pub(crate) profiles: HashMap<String, ProfileDef>,
27 /// Optional post-prompt text from the primary config.
28 pub(crate) post_prompt: Option<String>,
29}
30
31impl Config {
32 /// Return the unique library roots referenced by any loaded profile,
33 /// sorted for stable output.
34 #[must_use]
35 pub fn library_roots(&self) -> Vec<PathBuf> {
36 let mut roots: Vec<PathBuf> = Vec::new();
37 for def in self.profiles.values() {
38 if !roots.contains(&def.library_root) {
39 roots.push(def.library_root.clone());
40 }
41 }
42 roots.sort();
43 roots
44 }
45}
46
47pub(crate) fn load_bundle(
48 config_override: Option<&Path>,
49) -> Result<(PathBuf, Config), PrompterError> {
50 let cfg_path = resolve_config_path(config_override)?;
51 let default_library = if config_override.is_some() {
52 None
53 } else {
54 Some(library_dir()?)
55 };
56 let cfg = load_config_bundle(&cfg_path, default_library.as_deref())?;
57 Ok((cfg_path, cfg))
58}
59
60/// Raw parse of a single config file: profiles keyed by dotted name, plus
61/// the top-level reserved keys (`import`, `library`, `post_prompt`).
62///
63/// Unlike the merged [`Config`], this represents one file on disk before
64/// imports are resolved and before a library root is bound to each profile.
65#[derive(Debug)]
66pub(crate) struct RawConfigFile {
67 /// Map of dotted profile name -> its `depends_on` list.
68 pub(crate) profiles: HashMap<String, Vec<String>>,
69 /// Optional list of paths to other config files to import.
70 pub(crate) imports: Vec<String>,
71 /// Optional explicit library-root override (relative to this file's dir,
72 /// or absolute, or tilde-prefixed).
73 pub(crate) library: Option<String>,
74 /// Optional post-prompt (only honored for the primary config).
75 pub(crate) post_prompt: Option<String>,
76}
77
78/// Parse a single config file's text into a [`RawConfigFile`].
79///
80/// Uses the `toml` crate. Any top-level table that (directly or transitively)
81/// contains a `depends_on` array is treated as a profile; its dotted path
82/// becomes the profile name. This preserves the pre-existing behavior where
83/// `[python.api]` names a flat profile rather than a nested table.
84///
85/// # Errors
86/// Returns an error if the TOML is syntactically invalid or if a field has
87/// the wrong type (e.g. `import` is not an array of strings, `depends_on`
88/// contains non-string entries).
89pub(crate) fn parse_config_file(input: &str) -> Result<RawConfigFile, PrompterError> {
90 let table: toml::Table = toml::from_str(input)?;
91
92 let mut raw = RawConfigFile {
93 profiles: HashMap::new(),
94 imports: Vec::new(),
95 library: None,
96 post_prompt: None,
97 };
98
99 for (key, value) in &table {
100 match key.as_str() {
101 "import" => {
102 let arr = value.as_array().ok_or_else(|| {
103 PrompterError::ConfigField("`import` must be an array of strings".to_string())
104 })?;
105 for entry in arr {
106 let s = entry.as_str().ok_or_else(|| {
107 PrompterError::ConfigField("`import` entries must be strings".to_string())
108 })?;
109 raw.imports.push(s.to_string());
110 }
111 }
112 "library" => {
113 let s = value.as_str().ok_or_else(|| {
114 PrompterError::ConfigField("`library` must be a string".to_string())
115 })?;
116 raw.library = Some(s.to_string());
117 }
118 "post_prompt" => {
119 let s = value.as_str().ok_or_else(|| {
120 PrompterError::ConfigField("`post_prompt` must be a string".to_string())
121 })?;
122 raw.post_prompt = Some(s.to_string());
123 }
124 _ => collect_profiles(key, value, &mut raw.profiles)?,
125 }
126 }
127
128 Ok(raw)
129}