1use crate::build::{CatalogIndex, catalog_index_from_value};
4use crate::json_util::value_to_string;
5use crate::paths::{
6 self, decomposed_prefix, get_root_tool_key, json_ext, md_ext, tool_id_from_decomposed_rel,
7};
8use crate::policies::{
9 PolicyContext, ToolPolicy, append_description_reinstate_entries, effective_policy,
10 mcp_required_enum_values, needs_description_reinstate, required_enum_values_by_tool,
11 system_required_enum_values,
12};
13use crate::runtime_config;
14use serde_json::{Map, Value, json};
15use std::collections::{HashMap, HashSet};
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone, Default)]
20pub struct DecomposedCatalog {
21 pub(crate) json_files: HashMap<String, Value>,
23}
24
25impl DecomposedCatalog {
26 #[must_use]
28 pub const fn from_json_files(json_files: HashMap<String, Value>) -> Self {
29 Self { json_files }
30 }
31
32 #[must_use]
34 pub const fn json_files(&self) -> &HashMap<String, Value> {
35 &self.json_files
36 }
37
38 #[must_use]
40 pub fn from_catalog_index(index: &CatalogIndex) -> Self {
41 let mut json_files = HashMap::new();
42 for (rel_path, content) in &index.files {
43 if rel_path.starts_with(&decomposed_prefix())
44 && rel_path.ends_with(&json_ext())
45 && let Ok(parsed) = serde_json::from_str::<Value>(content)
46 && parsed.is_object()
47 {
48 json_files.insert(rel_path.clone(), parsed);
49 }
50 }
51 Self { json_files }
52 }
53
54 #[must_use]
56 pub fn from_catalog_dict(data: &Value) -> Self {
57 let mut json_files = HashMap::new();
58 if let Some(entries) = data.get("json").and_then(|v| v.as_array()) {
59 for entry in entries {
60 let Some(obj) = entry.as_object() else {
61 continue;
62 };
63 let Some(file_path) = obj.get("file_path").and_then(|v| v.as_str()) else {
64 continue;
65 };
66 let Some(content) = obj.get("content") else {
67 continue;
68 };
69 if !content.is_object() {
70 continue;
71 }
72 if let Some(key) = paths::to_decomposed_key(file_path) {
73 json_files.insert(key, content.clone());
74 }
75 }
76 }
77 Self { json_files }
78 }
79
80 pub fn merge_json_files(&mut self, other: &Self) {
82 self.json_files.extend(other.json_files.clone());
83 }
84
85 #[must_use]
87 pub fn resolve_key(&self, file_path: &str) -> Option<String> {
88 let mut candidates = Vec::new();
89 if let Some(normalized) = paths::to_decomposed_key(file_path) {
90 candidates.push(normalized);
91 }
92 candidates.push(file_path.to_string());
93 candidates
94 .into_iter()
95 .find(|candidate| self.has_json(candidate))
96 }
97
98 #[must_use]
100 pub fn has_json(&self, key: &str) -> bool {
101 self.json_files.contains_key(key)
102 }
103
104 #[must_use]
106 pub fn get_json(&self, key: &str) -> Option<&Value> {
107 self.json_files.get(key)
108 }
109}
110
111#[must_use]
113pub fn decomposed_catalog_from_value(val: &Value) -> DecomposedCatalog {
114 if val.get("tools").is_some() && val.get("files").is_some() {
115 let idx = catalog_index_from_value(val);
116 return DecomposedCatalog::from_catalog_index(&idx);
117 }
118 if let Some(map) = val.as_object() {
119 let mut json_files = HashMap::new();
120 for (k, v) in map {
121 if v.is_object() {
122 json_files.insert(k.clone(), v.clone());
123 }
124 }
125 if !json_files.is_empty() {
126 return DecomposedCatalog::from_json_files(json_files);
127 }
128 }
129 DecomposedCatalog::default()
130}
131
132#[must_use]
134pub fn deep_merge(base: &Value, override_val: &Value) -> Value {
135 match (base, override_val) {
136 (Value::Object(base_map), Value::Object(override_map)) => {
137 let mut result = base_map.clone();
138 for (key, val) in override_map {
139 if let Some(existing) = result.get(key)
140 && existing.is_object()
141 && val.is_object()
142 {
143 result.insert(key.clone(), deep_merge(existing, val));
144 continue;
145 }
146 result.insert(key.clone(), val.clone());
147 }
148 Value::Object(result)
149 }
150 _ => override_val.clone(),
151 }
152}
153
154#[must_use]
156pub fn climb_and_merge(leaf_path: &str, catalog: &DecomposedCatalog) -> Value {
157 let leaf_key = catalog.resolve_key(leaf_path).unwrap_or_else(|| {
158 paths::to_decomposed_key(leaf_path).unwrap_or_else(|| leaf_path.to_string())
159 });
160
161 let Some(mut current) = catalog.get_json(&leaf_key).cloned() else {
162 return json!({});
163 };
164
165 let mut current_path = PathBuf::from(&leaf_key);
166 current_path.pop();
167
168 let decomposed_root = paths::decomposed_root();
169
170 loop {
171 let parent_dir = current_path.parent().map(std::path::Path::to_path_buf);
172 let Some(parent_dir) = parent_dir else {
173 break;
174 };
175 if parent_dir == decomposed_root || !parent_dir.starts_with(&decomposed_root) {
176 break;
177 }
178
179 let parent_key = format!(
180 "{}/{}{}",
181 parent_dir.to_string_lossy(),
182 current_path
183 .file_name()
184 .unwrap_or_default()
185 .to_string_lossy(),
186 json_ext(),
187 );
188 if let Some(parent) = catalog.get_json(&parent_key) {
189 current = deep_merge(parent, ¤t);
190 }
191 current_path = parent_dir;
192 }
193 current
194}
195
196#[must_use]
198pub fn extract_scores(data: &Value) -> HashMap<String, f64> {
199 let mut scores = HashMap::new();
200 let Some(obj) = data.as_object() else {
201 return scores;
202 };
203 if let Some(md) = obj.get("md").and_then(|v| v.as_array()) {
204 for entry in md {
205 if let Some(e) = entry.as_object()
206 && let (Some(content), Some(score)) = (
207 e.get("content").and_then(|v| v.as_str()),
208 json_f64(e.get("score")),
209 )
210 {
211 scores.insert(content.to_string(), score);
212 }
213 }
214 }
215 if let Some(json_arr) = obj.get("json").and_then(|v| v.as_array()) {
216 for entry in json_arr {
217 if let Some(e) = entry.as_object()
218 && let (Some(fp), Some(score)) = (
219 e.get("file_path").and_then(|v| v.as_str()),
220 json_f64(e.get("score")),
221 )
222 {
223 scores.insert(fp.to_string(), score);
224 }
225 }
226 }
227 scores
228}
229
230fn json_f64(value: Option<&Value>) -> Option<f64> {
232 let v = value?;
233 if let Some(n) = v.as_f64() {
234 return Some(n);
235 }
236 v.as_str().and_then(|s| s.trim().parse::<f64>().ok())
237}
238
239fn extract_from_dict(
240 data: &Map<String, Value>,
241 apply_decomposed_score_filter: bool,
242) -> Vec<String> {
243 let mut input_files = Vec::new();
244 for (key, value) in data {
245 if key == "md" {
246 continue;
247 }
248 if let Some(arr) = value.as_array() {
249 for entry in arr {
250 if let Some(e) = entry.as_object()
251 && let Some(fp) = e.get("file_path").and_then(|v| v.as_str())
252 {
253 if key == "json" && apply_decomposed_score_filter {
254 let score = json_f64(e.get("score")).unwrap_or(0.0);
255 if score <= runtime_config::decomposed_score() {
256 continue;
257 }
258 }
259 input_files.push(fp.to_string());
260 }
261 }
262 } else if let Some(e) = value.as_object()
263 && let Some(fp) = e.get("file_path").and_then(|v| v.as_str())
264 {
265 input_files.push(fp.to_string());
266 }
267 }
268 input_files
269}
270
271#[must_use]
273pub fn extract_input_files(data: &Value, apply_decomposed_score_filter: bool) -> Vec<String> {
274 if let Some(obj) = data.as_object() {
275 return extract_from_dict(obj, apply_decomposed_score_filter);
276 }
277 if let Some(arr) = data.as_array() {
278 return arr
279 .iter()
280 .filter_map(|entry| {
281 entry
282 .as_object()
283 .and_then(|e| e.get("file_path"))
284 .and_then(|v| v.as_str())
285 .map(String::from)
286 })
287 .collect();
288 }
289 Vec::new()
290}
291
292#[must_use]
294pub fn parse_json_input(
295 data: &Value,
296 apply_decomposed_score_filter: bool,
297) -> (Vec<String>, HashMap<String, f64>) {
298 (
299 extract_input_files(data, apply_decomposed_score_filter),
300 extract_scores(data),
301 )
302}
303
304fn filter_items(items_with_scores: &[(Value, f64)]) -> Vec<Value> {
305 let threshold = runtime_config::enum_score();
306 let mut seen = HashSet::new();
307 let mut out = Vec::new();
308 for (item, _) in items_with_scores.iter().take(3) {
309 if seen.insert(value_to_string(item)) {
310 out.push(item.clone());
311 }
312 }
313 for (item, score) in items_with_scores.iter().skip(3) {
314 if *score >= threshold && seen.insert(value_to_string(item)) {
315 out.push(item.clone());
316 }
317 }
318 out
319}
320
321pub fn filter_and_sort_enums<S: std::hash::BuildHasher, P: std::hash::BuildHasher>(
323 schema: &mut Value,
324 scores: &HashMap<String, f64, S>,
325 preserve_values: Option<&HashSet<String, P>>,
326) {
327 match schema {
328 Value::Object(map) => {
329 let keys: Vec<String> = map.keys().cloned().collect();
330 for key in keys {
331 if key == "enum" {
332 if let Some(Value::Array(items)) = map.get("enum").cloned() {
333 let mut preserved = Vec::new();
334 let mut prunable = Vec::new();
335 for item in items {
336 if preserve_values
337 .is_some_and(|pv| pv.contains(&value_to_string(&item)))
338 {
339 preserved.push(item);
340 } else {
341 prunable.push(item);
342 }
343 }
344 let mut items_with_scores: Vec<(Value, f64)> = prunable
345 .into_iter()
346 .map(|item| {
347 let score =
348 scores.get(&value_to_string(&item)).copied().unwrap_or(0.0);
349 (item, score)
350 })
351 .collect();
352 items_with_scores.sort_by(|a, b| {
353 b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
354 });
355 preserved.extend(filter_items(&items_with_scores));
356 map.insert("enum".into(), Value::Array(preserved));
357 }
358 } else if let Some(val) = map.get(&key).cloned() {
359 let mut inner = val;
360 filter_and_sort_enums(&mut inner, scores, preserve_values);
361 map.insert(key, inner);
362 }
363 }
364 }
365 Value::Array(items) => {
366 for item in items.iter_mut() {
367 filter_and_sort_enums(item, scores, preserve_values);
368 }
369 }
370 _ => {}
371 }
372}
373
374#[must_use]
376pub fn group_files(
377 input_files: &[String],
378 catalog: &DecomposedCatalog,
379) -> (HashMap<String, Vec<String>>, HashSet<String>) {
380 let mut groups: HashMap<String, Vec<String>> = HashMap::new();
381 let mut tool_files = HashSet::new();
382 let decomposed_root = paths::decomposed_root();
383
384 for file_path in input_files {
385 let Some(key) = catalog.resolve_key(file_path) else {
386 eprintln!("Warning: File not found: {file_path}");
387 continue;
388 };
389 let rel = Path::new(&key)
390 .strip_prefix(&decomposed_root)
391 .unwrap_or_else(|_| Path::new(&key));
392 let parts: Vec<_> = rel.components().collect();
393 let is_tool = parts.len() == 1
394 && parts[0]
395 .as_os_str()
396 .to_string_lossy()
397 .ends_with(&json_ext());
398
399 let Some(root_tool) = paths::get_root_tool_key(&key) else {
400 continue;
401 };
402 if is_tool {
403 tool_files.insert(key.clone());
404 }
405 groups.entry(root_tool).or_default().push(key);
406 }
407 (groups, tool_files)
408}
409
410fn tool_shell_from_root_key(root_tool: &str) -> Value {
411 let name = Path::new(root_tool)
412 .file_stem()
413 .unwrap_or_default()
414 .to_string_lossy();
415 json!({
416 "name": name,
417 "inputSchema": {"type": "object", "properties": {}},
418 })
419}
420
421#[must_use]
423pub fn build_process_groups_options(
424 ctx: &PolicyContext,
425 catalog_dict: &Value,
426 store: &DecomposedCatalog,
427 preserve_values: Option<Vec<String>>,
428) -> ProcessGroupsOptions {
429 let mut system_preserve = system_required_enum_values(catalog_dict);
430 if let Some(pv) = preserve_values
431 && system_preserve.is_empty()
432 {
433 system_preserve = pv.into_iter().collect();
434 }
435 let mcp_preserve = mcp_required_enum_values(catalog_dict);
436 let required_by_tool = required_enum_values_by_tool(catalog_dict);
437
438 let mut prune_optional_tools = HashSet::new();
439 for key in store.json_files().keys() {
440 if let Some(root_tool) = get_root_tool_key(key) {
441 let tool_name = tool_id_from_decomposed_rel(&root_tool);
442 let policy = effective_policy(ctx, &tool_name);
443 if matches!(
444 policy,
445 ToolPolicy::PruneOptional | ToolPolicy::PruneOptionalDescriptions
446 ) {
447 prune_optional_tools.insert(tool_name);
448 }
449 }
450 }
451
452 ProcessGroupsOptions {
453 system_preserve: (!system_preserve.is_empty()).then_some(system_preserve),
454 mcp_preserve: (!mcp_preserve.is_empty()).then_some(mcp_preserve),
455 required_by_tool,
456 prune_optional_tools,
457 }
458}
459
460#[derive(Debug, Clone, Default)]
462pub struct ProcessGroupsOptions {
463 pub system_preserve: Option<HashSet<String>>,
465 pub mcp_preserve: Option<HashSet<String>>,
467 pub required_by_tool: HashMap<String, HashSet<String>>,
469 pub prune_optional_tools: HashSet<String>,
471}
472
473#[must_use]
475pub fn process_groups_options_from_fields<S: std::hash::BuildHasher + Default>(
476 system_preserve: Option<Vec<String>>,
477 mcp_preserve: Option<Vec<String>>,
478 required_by_tool: Option<HashMap<String, Vec<String>, S>>,
479 required_enum_values_by_tool: Option<HashMap<String, Vec<String>, S>>,
480 prune_optional_tools: Option<Vec<String>>,
481) -> ProcessGroupsOptions {
482 let required_by_tool = required_by_tool
483 .or(required_enum_values_by_tool)
484 .unwrap_or_default()
485 .into_iter()
486 .map(|(k, v)| (k, v.into_iter().collect()))
487 .collect();
488 ProcessGroupsOptions {
489 system_preserve: system_preserve.map(|items| items.into_iter().collect()),
490 mcp_preserve: mcp_preserve.map(|items| items.into_iter().collect()),
491 required_by_tool,
492 prune_optional_tools: prune_optional_tools
493 .unwrap_or_default()
494 .into_iter()
495 .collect(),
496 }
497}
498
499#[must_use]
501pub fn process_groups<S: std::hash::BuildHasher>(
502 groups: &HashMap<String, Vec<String>, S>,
503 tool_files: &HashSet<String, S>,
504 scores: &HashMap<String, f64, S>,
505 catalog: &DecomposedCatalog,
506 opts: &ProcessGroupsOptions,
507) -> Vec<Value> {
508 let mut tools = Vec::new();
509
510 for (root_tool, files) in groups {
511 let mut base_tool = catalog
512 .get_json(root_tool)
513 .cloned()
514 .unwrap_or_else(|| tool_shell_from_root_key(root_tool));
515
516 let tool_name_in_schema = base_tool
517 .get("name")
518 .and_then(|v| v.as_str())
519 .unwrap_or("")
520 .to_string();
521
522 for file_key in files {
523 if tool_files.contains(file_key) {
524 continue;
525 }
526 base_tool = deep_merge(&base_tool, &climb_and_merge(file_key, catalog));
527 }
528
529 let stem_name = Path::new(root_tool)
530 .file_stem()
531 .unwrap_or_default()
532 .to_string_lossy()
533 .into_owned();
534 let tool_name = base_tool
535 .get("name")
536 .and_then(|v| v.as_str())
537 .filter(|s| !s.is_empty())
538 .unwrap_or(if tool_name_in_schema.is_empty() {
539 stem_name.as_str()
540 } else {
541 tool_name_in_schema.as_str()
542 })
543 .to_string();
544
545 if let Some(obj) = base_tool.as_object().cloned() {
546 let mut obj = obj;
547 obj.insert("name".into(), Value::String(tool_name.clone()));
548 obj.remove("id");
549 base_tool = Value::Object(obj);
550 }
551
552 if !scores.is_empty() {
553 let enum_preserve = if opts.prune_optional_tools.contains(&tool_name) {
554 opts.required_by_tool
555 .get(&tool_name)
556 .cloned()
557 .or_else(|| opts.system_preserve.clone())
558 .or_else(|| opts.mcp_preserve.clone())
559 } else {
560 None
561 };
562 filter_and_sort_enums(&mut base_tool, scores, enum_preserve.as_ref());
563 }
564 tools.push(base_tool);
565 }
566 tools
567}
568
569#[derive(Debug, Clone, Default)]
571pub struct RetrieveOptions {
572 pub apply_decomposed_score_filter: bool,
574 pub process_groups: ProcessGroupsOptions,
576}
577
578pub fn resolve_build_catalog(catalog: &Value, survivor_data: &Value) -> Value {
580 if catalog.get("tools").is_some() && catalog.get("files").is_some() {
581 return catalog_index_from_value(catalog).to_catalog_dict();
582 }
583 if catalog
584 .get("json")
585 .and_then(Value::as_array)
586 .is_some_and(|arr| !arr.is_empty())
587 {
588 return catalog.clone();
589 }
590 survivor_data.clone()
591}
592pub fn apply_description_reinstate_to_data(
595 ctx: &PolicyContext,
596 data: &Value,
597 build_catalog: &Value,
598) -> (Value, DecomposedCatalog) {
599 let mut retrieve_data = data.clone();
600 let mut survivor = DecomposedCatalog::from_catalog_dict(data);
601 if !needs_description_reinstate(ctx) {
602 return (retrieve_data, survivor);
603 }
604
605 let json_entries = data
606 .get("json")
607 .and_then(Value::as_array)
608 .map_or(&[] as &[Value], std::vec::Vec::as_slice);
609 let empty_index = CatalogIndex {
610 tools: Vec::new(),
611 files: HashMap::new(),
612 };
613 let mitigated =
614 append_description_reinstate_entries(ctx, json_entries, build_catalog, &empty_index);
615 if let Some(obj) = retrieve_data.as_object_mut() {
616 obj.insert("json".into(), Value::Array(mitigated));
617 }
618 survivor = DecomposedCatalog::from_catalog_dict(&retrieve_data);
619 (retrieve_data, survivor)
620}
621
622pub fn retrieve_tools_from_catalog(
624 ctx: &PolicyContext,
625 data: &Value,
626 build_catalog: &Value,
627 store: &mut DecomposedCatalog,
628 opts: &RetrieveOptions,
629) -> Vec<Value> {
630 let (retrieve_data, survivor) = apply_description_reinstate_to_data(ctx, data, build_catalog);
631 retrieve_core(&retrieve_data, store, &survivor, opts)
632}
633
634pub fn retrieve_core(
636 data: &Value,
637 store: &mut DecomposedCatalog,
638 survivor_overlay: &DecomposedCatalog,
639 opts: &RetrieveOptions,
640) -> Vec<Value> {
641 if !survivor_overlay.json_files.is_empty() {
642 store.merge_json_files(survivor_overlay);
643 }
644
645 let (input_files, scores) = parse_json_input(data, opts.apply_decomposed_score_filter);
646 let (groups, tool_files) = group_files(&input_files, store);
647 process_groups(&groups, &tool_files, &scores, store, &opts.process_groups)
648}
649
650#[derive(Debug, Clone, Default)]
652pub struct RemovedChunksOptions {
653 pub apply_decomposed_score_filter: bool,
656}
657
658#[must_use]
660pub fn chunk_survivor_key(entry: &Value, section: &str) -> Option<String> {
661 let obj = entry.as_object()?;
662 if let Some(fp) = obj.get("file_path").and_then(|v| v.as_str()) {
663 return paths::to_decomposed_key(fp).or_else(|| Some(fp.to_string()));
664 }
665 if section == "md"
666 && let Some(content) = obj.get("content").and_then(|v| v.as_str())
667 {
668 return Some(format!("md:content:{content}"));
669 }
670 None
671}
672
673fn survivor_key_sets(
674 surviving: &Value,
675 apply_decomposed_score_filter: bool,
676) -> (HashSet<String>, HashSet<String>) {
677 let mut json_keys = HashSet::new();
678 let mut md_keys = HashSet::new();
679 let Some(obj) = surviving.as_object() else {
680 return (json_keys, md_keys);
681 };
682 if let Some(arr) = obj.get("json").and_then(|v| v.as_array()) {
683 for entry in arr {
684 let Some(e) = entry.as_object() else {
685 continue;
686 };
687 if apply_decomposed_score_filter {
688 let score = json_f64(e.get("score")).unwrap_or(0.0);
689 if score <= runtime_config::decomposed_score() {
690 continue;
691 }
692 }
693 if let Some(key) = chunk_survivor_key(entry, "json") {
694 json_keys.insert(key);
695 }
696 }
697 }
698 if let Some(arr) = obj.get("md").and_then(|v| v.as_array()) {
699 for entry in arr {
700 if let Some(key) = chunk_survivor_key(entry, "md") {
701 md_keys.insert(key);
702 }
703 }
704 }
705 (json_keys, md_keys)
706}
707
708fn removed_section(full: &Value, section: &str, survivor_keys: &HashSet<String>) -> Vec<Value> {
709 let Some(arr) = full.get(section).and_then(|v| v.as_array()) else {
710 return Vec::new();
711 };
712 let mut removed = Vec::new();
713 for entry in arr {
714 let key = chunk_survivor_key(entry, section);
715 if key.as_ref().is_some_and(|k| survivor_keys.contains(k)) {
716 continue;
717 }
718 removed.push(entry.clone());
719 }
720 removed
721}
722
723#[must_use]
725pub fn removed_chunks(
726 full_catalog: &Value,
727 surviving: &Value,
728 opts: &RemovedChunksOptions,
729) -> Value {
730 let (json_keys, md_keys) = survivor_key_sets(surviving, opts.apply_decomposed_score_filter);
731 let json = removed_section(full_catalog, "json", &json_keys);
732 let md = removed_section(full_catalog, "md", &md_keys);
733 json!({
734 "json": json,
735 "md": md,
736 })
737}
738
739pub fn load_catalog_from_dir(dir_path: &str) -> Result<Value, String> {
745 let root = Path::new(dir_path);
746 if !root.is_dir() {
747 return Err(format!("Directory not found: {dir_path}"));
748 }
749
750 let mut md_entries = Vec::new();
751 let mut json_entries = Vec::new();
752
753 for entry in walkdir_light(root)? {
754 let path = entry;
755 if !path.is_file() {
756 continue;
757 }
758 let path_str = path.to_string_lossy();
759 if !paths::is_catalog_decomposed_path(&path_str) {
760 continue;
761 }
762 let suffix = path.extension().and_then(|s| s.to_str()).unwrap_or("");
763 let is_skills_md = paths::to_skills_decomposed_key(&path_str).is_some()
764 && suffix.eq_ignore_ascii_case(trim_dot(&md_ext()))
765 && path.file_name().and_then(|n| n.to_str()) != Some("page_index.json")
766 && path.file_name().and_then(|n| n.to_str()) != Some("chunk_index.json");
767 if is_skills_md
768 || (paths::to_decomposed_key(&path_str).is_some()
769 && suffix.eq_ignore_ascii_case(trim_dot(&md_ext())))
770 {
771 if let Ok(content) = std::fs::read_to_string(&path) {
772 md_entries.push(json!({
773 "id": path.file_stem().unwrap_or_default().to_string_lossy(),
774 "file_path": path.to_string_lossy(),
775 "score": 0.0,
776 "start_line": 1,
777 "end_line": 1,
778 "language": "markdown",
779 "content": content,
780 }));
781 }
782 } else if suffix.eq_ignore_ascii_case(trim_dot(&json_ext()))
783 && paths::to_decomposed_key(&path_str).is_some()
784 {
785 let raw_text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
786 let content: Value = serde_json::from_str(&raw_text).map_err(|e| e.to_string())?;
787 let line_count = raw_text.lines().count();
788 let rel_path = path.to_string_lossy();
789 let decomposed_key = paths::to_decomposed_key(&rel_path);
790 let entry_id = content
791 .get("id")
792 .cloned()
793 .or_else(|| {
794 decomposed_key
795 .as_ref()
796 .map(|k| Value::String(paths::tool_id_from_decomposed_rel(k)))
797 })
798 .unwrap_or_else(|| {
799 Value::String(
800 path.file_stem()
801 .unwrap_or_default()
802 .to_string_lossy()
803 .into_owned(),
804 )
805 });
806 json_entries.push(json!({
807 "id": entry_id,
808 "name": entry_id,
809 "file_path": rel_path,
810 "score": 0.0,
811 "start_line": 1,
812 "end_line": line_count,
813 "language": "json",
814 "content": content,
815 }));
816 }
817 }
818
819 if md_entries.is_empty() && json_entries.is_empty() {
820 eprintln!("Warning: No .json or .md files found in {dir_path}");
821 }
822
823 Ok(json!({
824 "md": md_entries,
825 "json": json_entries,
826 }))
827}
828
829fn trim_dot(ext: &str) -> &str {
830 ext.strip_prefix('.').unwrap_or(ext)
831}
832
833fn walkdir_light(root: &Path) -> Result<Vec<PathBuf>, String> {
834 let mut stack = vec![root.to_path_buf()];
835 let mut files = Vec::new();
836 while let Some(dir) = stack.pop() {
837 let entries = std::fs::read_dir(&dir).map_err(|e| e.to_string())?;
838 for entry in entries {
839 let entry = entry.map_err(|e| e.to_string())?;
840 let path = entry.path();
841 if path.is_dir() {
842 stack.push(path);
843 } else {
844 files.push(path);
845 }
846 }
847 }
848 Ok(files)
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854 use serde_json::json;
855
856 #[test]
857 fn low_rerank_scores_kept_without_score_filter() {
858 let data = json!({
859 "json": [{
860 "file_path": "schemas/decomposed/Agent.json",
861 "score": "0.003",
862 }]
863 });
864 let files = extract_input_files(&data, false);
865 assert_eq!(files.len(), 1);
866 }
867
868 #[test]
869 fn low_rerank_scores_dropped_with_score_filter() {
870 let data = json!({
871 "json": [{
872 "file_path": "schemas/decomposed/Agent.json",
873 "score": "0.003",
874 }]
875 });
876 let files = extract_input_files(&data, true);
877 assert!(files.is_empty());
878 }
879
880 #[test]
881 fn removed_chunks_excludes_survivors_by_decomposed_key() {
882 let full = json!({
883 "json": [
884 {"file_path": "schemas/decomposed/Agent.json", "content": {"name": "Agent"}},
885 {"file_path": "schemas/decomposed/Agent/extra.json", "content": {}},
886 ],
887 "md": [
888 {"file_path": "schemas/decomposed/haiku.md", "content": "haiku"},
889 {"file_path": "schemas/decomposed/sonnet.md", "content": "sonnet"},
890 ],
891 });
892 let surviving = json!({
893 "json": [{"file_path": "src/catalog/schemas/decomposed/Agent.json"}],
894 "md": [{"file_path": "src/catalog/schemas/decomposed/haiku.md"}],
895 });
896 let removed = removed_chunks(&full, &surviving, &RemovedChunksOptions::default());
897 let json_removed = removed.get("json").and_then(Value::as_array);
898 assert_eq!(json_removed.map(std::vec::Vec::len), Some(1));
899 assert_eq!(
900 json_removed
901 .and_then(|entries| entries.first())
902 .and_then(|entry| entry.get("file_path"))
903 .and_then(Value::as_str),
904 Some("schemas/decomposed/Agent/extra.json")
905 );
906 let md_removed = removed.get("md").and_then(Value::as_array);
907 assert_eq!(md_removed.map(std::vec::Vec::len), Some(1));
908 assert_eq!(
909 md_removed
910 .and_then(|entries| entries.first())
911 .and_then(|entry| entry.get("file_path"))
912 .and_then(Value::as_str),
913 Some("schemas/decomposed/sonnet.md")
914 );
915 }
916
917 fn scored_enum_items(values: &[(&str, f64)]) -> Vec<(Value, f64)> {
918 values
919 .iter()
920 .map(|(value, score)| (Value::String(value.to_string()), *score))
921 .collect()
922 }
923
924 fn enum_strings(items: &[(Value, f64)]) -> Vec<String> {
925 filter_items(items)
926 .into_iter()
927 .map(|v| v.as_str().unwrap_or_default().to_string())
928 .collect()
929 }
930
931 #[test]
932 fn filter_items_hedl_convert_to_format_keeps_top_three() {
933 let items = scored_enum_items(&[
934 ("yaml", 0.95),
935 ("csv", 0.89),
936 ("json", 0.0005),
937 ("xml", 0.0004),
938 ("parquet", 0.0003),
939 ("cypher", 0.0002),
940 ("toon", 0.0001),
941 ]);
942 assert_eq!(enum_strings(&items), vec!["yaml", "csv", "json"]);
943 }
944
945 #[test]
946 fn filter_items_index_folder_identity_mode_keeps_top_three() {
947 let items = scored_enum_items(&[("git", 0.9), ("local", 0.0008), ("config", 0.00006)]);
948 assert_eq!(enum_strings(&items), vec!["git", "local", "config"]);
949 }
950
951 #[test]
952 fn filter_items_includes_rank_four_plus_above_threshold() {
953 let items = scored_enum_items(&[
954 ("a", 0.9),
955 ("b", 0.88),
956 ("c", 0.87),
957 ("d", 0.86),
958 ("e", 0.1),
959 ]);
960 assert_eq!(enum_strings(&items), vec!["a", "b", "c", "d"]);
961 }
962
963 #[test]
964 fn filter_items_short_list_keeps_all() {
965 let items = scored_enum_items(&[("only", 0.01), ("two", 0.02)]);
966 assert_eq!(enum_strings(&items), vec!["only", "two"]);
967 }
968
969 #[test]
970 fn filter_and_sort_enums_prepends_preserved_values() {
971 let mut schema = json!({
972 "type": "string",
973 "enum": ["yaml", "csv", "json", "xml"]
974 });
975 let scores = HashMap::from([
976 ("yaml".to_string(), 0.95),
977 ("csv".to_string(), 0.89),
978 ("json".to_string(), 0.0005),
979 ("xml".to_string(), 0.0004),
980 ]);
981 let preserve: HashSet<String> = HashSet::from(["xml".to_string()]);
982 filter_and_sort_enums(&mut schema, &scores, Some(&preserve));
983 let kept = schema
984 .get("enum")
985 .and_then(Value::as_array)
986 .cloned()
987 .unwrap_or_default();
988 let kept: Vec<_> = kept.iter().filter_map(|v| v.as_str()).collect();
989 assert_eq!(kept, vec!["xml", "yaml", "csv", "json"]);
990 }
991
992 #[test]
993 fn removed_chunks_respects_score_filter_on_survivors() {
994 let full = json!({
995 "json": [
996 {"file_path": "schemas/decomposed/Keep.json", "score": 0.9},
997 {"file_path": "schemas/decomposed/Drop.json", "score": 0.9},
998 ],
999 });
1000 let surviving = json!({
1001 "json": [
1002 {"file_path": "schemas/decomposed/Keep.json", "score": 0.9},
1003 {"file_path": "schemas/decomposed/Drop.json", "score": 0.1},
1004 ],
1005 });
1006 let removed = removed_chunks(
1007 &full,
1008 &surviving,
1009 &RemovedChunksOptions {
1010 apply_decomposed_score_filter: true,
1011 },
1012 );
1013 let json_removed = removed.get("json").and_then(Value::as_array);
1014 assert_eq!(json_removed.map(std::vec::Vec::len), Some(1));
1015 assert_eq!(
1016 json_removed
1017 .and_then(|entries| entries.first())
1018 .and_then(|entry| entry.get("file_path"))
1019 .and_then(Value::as_str),
1020 Some("schemas/decomposed/Drop.json")
1021 );
1022 }
1023}