1use crate::build::{CatalogIndex, catalog_index_from_value};
4use crate::paths::{
5 self, decomposed_prefix, get_root_tool_key, json_ext, md_ext, tool_id_from_decomposed_rel,
6};
7use crate::policies::{
8 PolicyContext, ToolPolicy, append_description_reinstate_entries, effective_policy,
9 mcp_required_enum_values, needs_description_reinstate, required_enum_values_by_tool,
10 system_required_enum_values,
11};
12use crate::runtime_config;
13use serde_json::{Map, Value, json};
14use std::collections::{HashMap, HashSet};
15use std::path::{Path, PathBuf};
16
17#[derive(Debug, Clone, Default)]
19pub struct DecomposedCatalog {
20 pub(crate) json_files: HashMap<String, Value>,
22}
23
24impl DecomposedCatalog {
25 #[must_use]
27 pub const fn from_json_files(json_files: HashMap<String, Value>) -> Self {
28 Self { json_files }
29 }
30
31 #[must_use]
33 pub const fn json_files(&self) -> &HashMap<String, Value> {
34 &self.json_files
35 }
36
37 #[must_use]
39 pub fn from_catalog_index(index: &CatalogIndex) -> Self {
40 let mut json_files = HashMap::new();
41 for (rel_path, content) in &index.files {
42 if rel_path.starts_with(&decomposed_prefix())
43 && rel_path.ends_with(&json_ext())
44 && let Ok(parsed) = serde_json::from_str::<Value>(content)
45 && parsed.is_object()
46 {
47 json_files.insert(rel_path.clone(), parsed);
48 }
49 }
50 Self { json_files }
51 }
52
53 #[must_use]
55 pub fn from_catalog_dict(data: &Value) -> Self {
56 let mut json_files = HashMap::new();
57 if let Some(entries) = data.get("json").and_then(|v| v.as_array()) {
58 for entry in entries {
59 let Some(obj) = entry.as_object() else {
60 continue;
61 };
62 let Some(file_path) = obj.get("file_path").and_then(|v| v.as_str()) else {
63 continue;
64 };
65 let Some(content) = obj.get("content") else {
66 continue;
67 };
68 if !content.is_object() {
69 continue;
70 }
71 if let Some(key) = paths::to_decomposed_key(file_path) {
72 json_files.insert(key, content.clone());
73 }
74 }
75 }
76 Self { json_files }
77 }
78
79 pub fn merge_json_files(&mut self, other: &Self) {
81 self.json_files.extend(other.json_files.clone());
82 }
83
84 #[must_use]
86 pub fn resolve_key(&self, file_path: &str) -> Option<String> {
87 let mut candidates = Vec::new();
88 if let Some(normalized) = paths::to_decomposed_key(file_path) {
89 candidates.push(normalized);
90 }
91 candidates.push(file_path.to_string());
92 candidates
93 .into_iter()
94 .find(|candidate| self.has_json(candidate))
95 }
96
97 #[must_use]
99 pub fn has_json(&self, key: &str) -> bool {
100 self.json_files.contains_key(key)
101 }
102
103 #[must_use]
105 pub fn get_json(&self, key: &str) -> Option<&Value> {
106 self.json_files.get(key)
107 }
108}
109
110#[must_use]
112pub fn decomposed_catalog_from_value(val: &Value) -> DecomposedCatalog {
113 if val.get("tools").is_some() && val.get("files").is_some() {
114 let idx = catalog_index_from_value(val);
115 return DecomposedCatalog::from_catalog_index(&idx);
116 }
117 if let Some(map) = val.as_object() {
118 let mut json_files = HashMap::new();
119 for (k, v) in map {
120 if v.is_object() {
121 json_files.insert(k.clone(), v.clone());
122 }
123 }
124 if !json_files.is_empty() {
125 return DecomposedCatalog::from_json_files(json_files);
126 }
127 }
128 DecomposedCatalog::default()
129}
130
131#[must_use]
133pub fn deep_merge(base: &Value, override_val: &Value) -> Value {
134 match (base, override_val) {
135 (Value::Object(base_map), Value::Object(override_map)) => {
136 let mut result = base_map.clone();
137 for (key, val) in override_map {
138 if let Some(existing) = result.get(key)
139 && existing.is_object()
140 && val.is_object()
141 {
142 result.insert(key.clone(), deep_merge(existing, val));
143 continue;
144 }
145 result.insert(key.clone(), val.clone());
146 }
147 Value::Object(result)
148 }
149 _ => override_val.clone(),
150 }
151}
152
153#[must_use]
155pub fn climb_and_merge(leaf_path: &str, catalog: &DecomposedCatalog) -> Value {
156 let leaf_key = catalog.resolve_key(leaf_path).unwrap_or_else(|| {
157 paths::to_decomposed_key(leaf_path).unwrap_or_else(|| leaf_path.to_string())
158 });
159
160 let Some(mut current) = catalog.get_json(&leaf_key).cloned() else {
161 return json!({});
162 };
163
164 let mut current_path = PathBuf::from(&leaf_key);
165 current_path.pop();
166
167 let decomposed_root = paths::decomposed_root();
168
169 loop {
170 let parent_dir = current_path.parent().map(std::path::Path::to_path_buf);
171 let Some(parent_dir) = parent_dir else {
172 break;
173 };
174 if parent_dir == decomposed_root || !parent_dir.starts_with(&decomposed_root) {
175 break;
176 }
177
178 let parent_key = format!(
179 "{}/{}{}",
180 parent_dir.to_string_lossy(),
181 current_path
182 .file_name()
183 .unwrap_or_default()
184 .to_string_lossy(),
185 json_ext(),
186 );
187 if let Some(parent) = catalog.get_json(&parent_key) {
188 current = deep_merge(parent, ¤t);
189 }
190 current_path = parent_dir;
191 }
192 current
193}
194
195#[must_use]
197pub fn extract_scores(data: &Value) -> HashMap<String, f64> {
198 let mut scores = HashMap::new();
199 let Some(obj) = data.as_object() else {
200 return scores;
201 };
202 if let Some(md) = obj.get("md").and_then(|v| v.as_array()) {
203 for entry in md {
204 if let Some(e) = entry.as_object()
205 && let (Some(content), Some(score)) = (
206 e.get("content").and_then(|v| v.as_str()),
207 json_f64(e.get("score")),
208 )
209 {
210 scores.insert(content.to_string(), score);
211 }
212 }
213 }
214 if let Some(json_arr) = obj.get("json").and_then(|v| v.as_array()) {
215 for entry in json_arr {
216 if let Some(e) = entry.as_object()
217 && let (Some(fp), Some(score)) = (
218 e.get("file_path").and_then(|v| v.as_str()),
219 json_f64(e.get("score")),
220 )
221 {
222 scores.insert(fp.to_string(), score);
223 }
224 }
225 }
226 scores
227}
228
229fn json_f64(value: Option<&Value>) -> Option<f64> {
231 let v = value?;
232 if let Some(n) = v.as_f64() {
233 return Some(n);
234 }
235 v.as_str().and_then(|s| s.trim().parse::<f64>().ok())
236}
237
238fn extract_from_dict(
239 data: &Map<String, Value>,
240 apply_decomposed_score_filter: bool,
241) -> Vec<String> {
242 let mut input_files = Vec::new();
243 for (key, value) in data {
244 if key == "md" {
245 continue;
246 }
247 if let Some(arr) = value.as_array() {
248 for entry in arr {
249 if let Some(e) = entry.as_object()
250 && let Some(fp) = e.get("file_path").and_then(|v| v.as_str())
251 {
252 if key == "json" && apply_decomposed_score_filter {
253 let score = json_f64(e.get("score")).unwrap_or(0.0);
254 if score <= runtime_config::decomposed_score() {
255 continue;
256 }
257 }
258 input_files.push(fp.to_string());
259 }
260 }
261 } else if let Some(e) = value.as_object()
262 && let Some(fp) = e.get("file_path").and_then(|v| v.as_str())
263 {
264 input_files.push(fp.to_string());
265 }
266 }
267 input_files
268}
269
270#[must_use]
272pub fn extract_input_files(data: &Value, apply_decomposed_score_filter: bool) -> Vec<String> {
273 if let Some(obj) = data.as_object() {
274 return extract_from_dict(obj, apply_decomposed_score_filter);
275 }
276 if let Some(arr) = data.as_array() {
277 return arr
278 .iter()
279 .filter_map(|entry| {
280 entry
281 .as_object()
282 .and_then(|e| e.get("file_path"))
283 .and_then(|v| v.as_str())
284 .map(String::from)
285 })
286 .collect();
287 }
288 Vec::new()
289}
290
291#[must_use]
293pub fn parse_json_input(
294 data: &Value,
295 apply_decomposed_score_filter: bool,
296) -> (Vec<String>, HashMap<String, f64>) {
297 (
298 extract_input_files(data, apply_decomposed_score_filter),
299 extract_scores(data),
300 )
301}
302
303fn filter_items(items_with_scores: &[(Value, f64)]) -> Vec<Value> {
304 let first_3_above = items_with_scores
305 .iter()
306 .take(3)
307 .all(|(_, score)| *score >= runtime_config::enum_score());
308
309 if first_3_above {
310 items_with_scores
311 .iter()
312 .filter(|(_, score)| *score >= runtime_config::enum_score())
313 .map(|(item, _)| item.clone())
314 .collect()
315 } else {
316 items_with_scores
317 .iter()
318 .take(3)
319 .map(|(item, _)| item.clone())
320 .collect()
321 }
322}
323
324pub fn filter_and_sort_enums<S: std::hash::BuildHasher, P: std::hash::BuildHasher>(
326 schema: &mut Value,
327 scores: &HashMap<String, f64, S>,
328 preserve_values: Option<&HashSet<String, P>>,
329) {
330 match schema {
331 Value::Object(map) => {
332 let keys: Vec<String> = map.keys().cloned().collect();
333 for key in keys {
334 if key == "enum" {
335 if let Some(Value::Array(items)) = map.get("enum").cloned() {
336 let mut preserved = Vec::new();
337 let mut prunable = Vec::new();
338 for item in items {
339 if preserve_values.is_some_and(|pv| pv.contains(&item.to_string())) {
340 preserved.push(item);
341 } else {
342 prunable.push(item);
343 }
344 }
345 let mut items_with_scores: Vec<(Value, f64)> = prunable
346 .into_iter()
347 .map(|item| {
348 let score = scores.get(&item.to_string()).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 #[test]
918 fn removed_chunks_respects_score_filter_on_survivors() {
919 let full = json!({
920 "json": [
921 {"file_path": "schemas/decomposed/Keep.json", "score": 0.9},
922 {"file_path": "schemas/decomposed/Drop.json", "score": 0.9},
923 ],
924 });
925 let surviving = json!({
926 "json": [
927 {"file_path": "schemas/decomposed/Keep.json", "score": 0.9},
928 {"file_path": "schemas/decomposed/Drop.json", "score": 0.1},
929 ],
930 });
931 let removed = removed_chunks(
932 &full,
933 &surviving,
934 &RemovedChunksOptions {
935 apply_decomposed_score_filter: true,
936 },
937 );
938 let json_removed = removed.get("json").and_then(Value::as_array);
939 assert_eq!(json_removed.map(std::vec::Vec::len), Some(1));
940 assert_eq!(
941 json_removed
942 .and_then(|entries| entries.first())
943 .and_then(|entry| entry.get("file_path"))
944 .and_then(Value::as_str),
945 Some("schemas/decomposed/Drop.json")
946 );
947 }
948}