Skip to main content

tftio_lib/prompt/
config.rs

1//! Configuration types and config file loading.
2use super::{
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}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn parse_config_rejects_non_string_import_entry() {
137        // `import` is an array here, but a non-string entry is rejected with a
138        // `ConfigField` error naming the offending key.
139        let err = parse_config_file("import = [123]\n").unwrap_err();
140        assert!(matches!(err, PrompterError::ConfigField(_)), "err={err}");
141        assert!(
142            err.to_string().contains("`import` entries must be strings"),
143            "err={err}"
144        );
145    }
146
147    #[test]
148    fn parse_config_rejects_non_string_library() {
149        // The reserved `library` key must be a string, not an integer.
150        let err = parse_config_file("library = 123\n").unwrap_err();
151        assert!(matches!(err, PrompterError::ConfigField(_)), "err={err}");
152        assert!(
153            err.to_string().contains("`library` must be a string"),
154            "err={err}"
155        );
156    }
157
158    #[test]
159    fn parse_config_rejects_non_string_post_prompt() {
160        // The reserved `post_prompt` key must be a string, not a boolean.
161        let err = parse_config_file("post_prompt = true\n").unwrap_err();
162        assert!(matches!(err, PrompterError::ConfigField(_)), "err={err}");
163        assert!(
164            err.to_string().contains("`post_prompt` must be a string"),
165            "err={err}"
166        );
167    }
168}