Skip to main content

chunk_your_tools/
paths.rs

1use serde_json::Value;
2use std::path::{Component, Path, PathBuf};
3use std::sync::{OnceLock, RwLock};
4
5/// SDK runtime defaults (paths + catalog I/O); override from the host app via `configure`.
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct PathConfig {
8    pub json_ext: String,
9    pub md_ext: String,
10    pub decomposed_prefix: String,
11    pub decomposed_root: PathBuf,
12    pub skills_decomposed_prefix: String,
13    pub skills_decomposed_root: PathBuf,
14    pub catalog_prefix: String,
15    pub builder_memory_only: bool,
16    pub default_catalog_dir: PathBuf,
17    pub write_catalog_prune: bool,
18}
19
20impl Default for PathConfig {
21    fn default() -> Self {
22        Self {
23            json_ext: ".json".to_string(),
24            md_ext: ".md".to_string(),
25            decomposed_prefix: "schemas/decomposed/".to_string(),
26            decomposed_root: PathBuf::from("schemas/decomposed"),
27            skills_decomposed_prefix: "skills/decomposed/".to_string(),
28            skills_decomposed_root: PathBuf::from("skills/decomposed"),
29            catalog_prefix: "catalog".to_string(),
30            builder_memory_only: false,
31            default_catalog_dir: PathBuf::from("catalog"),
32            write_catalog_prune: true,
33        }
34    }
35}
36
37fn config_lock() -> &'static RwLock<PathConfig> {
38    static CONFIG: OnceLock<RwLock<PathConfig>> = OnceLock::new();
39    CONFIG.get_or_init(|| RwLock::new(PathConfig::default()))
40}
41
42pub fn configure(cfg: PathConfig) {
43    *config_lock()
44        .write()
45        .unwrap_or_else(std::sync::PoisonError::into_inner) = cfg;
46}
47
48#[must_use]
49pub fn snapshot() -> PathConfig {
50    config_lock()
51        .read()
52        .unwrap_or_else(std::sync::PoisonError::into_inner)
53        .clone()
54}
55
56#[must_use]
57pub fn json_ext() -> String {
58    snapshot().json_ext
59}
60
61#[must_use]
62pub fn md_ext() -> String {
63    snapshot().md_ext
64}
65
66#[must_use]
67pub fn decomposed_prefix() -> String {
68    snapshot().decomposed_prefix
69}
70
71#[must_use]
72pub fn decomposed_root() -> PathBuf {
73    snapshot().decomposed_root
74}
75
76#[must_use]
77pub fn catalog_prefix() -> String {
78    snapshot().catalog_prefix
79}
80
81#[must_use]
82pub fn builder_memory_only() -> bool {
83    snapshot().builder_memory_only
84}
85
86#[must_use]
87pub fn default_catalog_dir() -> PathBuf {
88    snapshot().default_catalog_dir
89}
90
91#[must_use]
92pub fn write_catalog_prune() -> bool {
93    snapshot().write_catalog_prune
94}
95
96#[must_use]
97pub fn skills_decomposed_prefix() -> String {
98    snapshot().skills_decomposed_prefix
99}
100
101#[must_use]
102pub fn to_skills_decomposed_key(file_path: &str) -> Option<String> {
103    let parts: Vec<_> = Path::new(file_path).components().collect();
104    for i in 0..parts.len().saturating_sub(1) {
105        if parts[i] == Component::Normal("skills".as_ref())
106            && parts[i + 1] == Component::Normal("decomposed".as_ref())
107        {
108            let sub: PathBuf = parts[i..].iter().collect();
109            return Some(normalize_path_separators(&sub.to_string_lossy()));
110        }
111    }
112    None
113}
114
115#[must_use]
116pub fn is_catalog_decomposed_path(file_path: &str) -> bool {
117    to_decomposed_key(file_path).is_some() || to_skills_decomposed_key(file_path).is_some()
118}
119
120#[must_use]
121pub fn to_decomposed_key(file_path: &str) -> Option<String> {
122    let parts: Vec<_> = Path::new(file_path).components().collect();
123    for i in 0..parts.len().saturating_sub(1) {
124        if parts[i] == Component::Normal("schemas".as_ref())
125            && parts[i + 1] == Component::Normal("decomposed".as_ref())
126        {
127            let sub: PathBuf = parts[i..].iter().collect();
128            return Some(normalize_path_separators(&sub.to_string_lossy()));
129        }
130    }
131    None
132}
133
134#[must_use]
135pub fn tool_id_from_decomposed_rel(rel_path: &str) -> String {
136    let cfg = snapshot();
137    let rel = rel_path
138        .strip_prefix(&cfg.decomposed_prefix)
139        .map_or(rel_path, |stripped| stripped);
140    let path = Path::new(rel);
141    let parts: Vec<_> = path.components().collect();
142    if parts.is_empty() {
143        return path
144            .file_stem()
145            .unwrap_or_default()
146            .to_string_lossy()
147            .into_owned();
148    }
149    let first = parts[0].as_os_str().to_string_lossy();
150    if first.ends_with(&cfg.json_ext) {
151        first.trim_end_matches(&cfg.json_ext).to_string()
152    } else {
153        first.into_owned()
154    }
155}
156
157#[must_use]
158pub fn get_root_tool_key(file_path: &str) -> Option<String> {
159    let cfg = snapshot();
160    let key = to_decomposed_key(file_path)?;
161    let root = cfg.decomposed_root.clone();
162    let rel = Path::new(&key).strip_prefix(&root).ok()?;
163    if rel.as_os_str().is_empty() {
164        return None;
165    }
166    let parts: Vec<_> = rel.components().collect();
167    if parts.len() == 1 {
168        let name = parts[0].as_os_str().to_string_lossy();
169        if name.ends_with(&cfg.json_ext) {
170            return Some(key);
171        }
172    }
173    let tool_id = parts[0].as_os_str().to_string_lossy();
174    Some(format!(
175        "{}{}{}",
176        cfg.decomposed_prefix, tool_id, cfg.json_ext
177    ))
178}
179
180/// User home directory from `HOME` or `USERPROFILE`.
181///
182/// # Errors
183///
184/// Returns an error when neither variable is set.
185pub fn home_dir() -> Result<PathBuf, String> {
186    for key in ["HOME", "USERPROFILE"] {
187        if let Ok(value) = std::env::var(key)
188            && !value.is_empty()
189        {
190            return Ok(PathBuf::from(value));
191        }
192    }
193    Err("home directory not found (HOME/USERPROFILE unset)".to_string())
194}
195
196#[must_use]
197pub fn normalize_path_separators(path: &str) -> String {
198    path.replace('\\', "/")
199}
200
201/// Expand `~/…` prefixes against [`home_dir`].
202///
203/// # Errors
204///
205/// Returns an error when tilde expansion requires home resolution and it fails.
206pub fn expand_home_path(path: &Path) -> Result<PathBuf, String> {
207    let s = path.to_string_lossy();
208    if s == "~" {
209        return home_dir();
210    }
211    if let Some(stripped) = s.strip_prefix("~/") {
212        return Ok(home_dir()?.join(stripped));
213    }
214    Ok(path.to_path_buf())
215}
216
217/// Rewrite absolute paths under the user's home as `~/…`.
218///
219/// # Errors
220///
221/// Returns an error when home resolution or path expansion fails.
222pub fn shorten_home_path(path: &str) -> Result<String, String> {
223    let expanded = expand_home_path(Path::new(path))?;
224    let home = normalize_path_separators(&home_dir()?.to_string_lossy());
225    let path_str = normalize_path_separators(&expanded.to_string_lossy());
226    if path_str == home {
227        return Ok("~".to_string());
228    }
229    let home_prefix = format!("{home}/");
230    if let Some(rest) = path_str.strip_prefix(&home_prefix) {
231        return Ok(format!("~/{rest}"));
232    }
233    Ok(path_str)
234}
235
236#[must_use]
237pub fn collect_enums(schema: &Value) -> Vec<Value> {
238    let mut found = Vec::new();
239    collect_enums_inner(schema, &mut found);
240    found
241}
242
243fn collect_enums_inner(node: &Value, found: &mut Vec<Value>) {
244    match node {
245        Value::Object(map) => {
246            if let Some(Value::Array(items)) = map.get("enum") {
247                found.extend(items.iter().cloned());
248            }
249            for val in map.values() {
250                if val.is_object() || val.is_array() {
251                    collect_enums_inner(val, found);
252                }
253            }
254        }
255        Value::Array(items) => {
256            for item in items {
257                if item.is_object() || item.is_array() {
258                    collect_enums_inner(item, found);
259                }
260            }
261        }
262        _ => {}
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn default_prefix_round_trip() {
272        let cfg = PathConfig::default();
273        configure(cfg);
274        let rel = format!("{}tool.json", decomposed_prefix());
275        assert_eq!(tool_id_from_decomposed_rel(&rel), "tool");
276    }
277
278    #[test]
279    fn to_decomposed_key_uses_forward_slashes() -> Result<(), String> {
280        let key = to_decomposed_key("catalog/schemas/decomposed/Agent.json")
281            .ok_or_else(|| "expected decomposed key".to_string())?;
282        assert_eq!(key, "schemas/decomposed/Agent.json");
283        assert!(!key.contains('\\'));
284        Ok(())
285    }
286
287    #[test]
288    fn shorten_home_path_normalizes_separators() -> Result<(), String> {
289        let home = home_dir()?;
290        let home_norm = normalize_path_separators(&home.to_string_lossy());
291        let nested = format!("{home_norm}/.chunk-your-tools-test/example.md");
292        let with_backslashes = nested.replace('/', "\\");
293        assert_eq!(
294            shorten_home_path(&with_backslashes)?,
295            "~/.chunk-your-tools-test/example.md"
296        );
297        Ok(())
298    }
299}