chunk_your_tools/
paths.rs1use serde_json::Value;
2use std::path::{Component, Path, PathBuf};
3use std::sync::{OnceLock, RwLock};
4
5#[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
180pub 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
201pub 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
217pub 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}