Skip to main content

chunk_your_tools/
retrieve.rs

1//! Decomposed catalog retrieval: merge tool schemas, score filtering, and enum pruning.
2
3use 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/// In-memory map of decomposed JSON schema files keyed by catalog-relative path.
19#[derive(Debug, Clone, Default)]
20pub struct DecomposedCatalog {
21    /// Parsed JSON object per decomposed file path.
22    pub(crate) json_files: HashMap<String, Value>,
23}
24
25impl DecomposedCatalog {
26    /// Wrap a pre-built path-to-JSON map.
27    #[must_use]
28    pub const fn from_json_files(json_files: HashMap<String, Value>) -> Self {
29        Self { json_files }
30    }
31
32    /// Borrow the underlying path-to-JSON map.
33    #[must_use]
34    pub const fn json_files(&self) -> &HashMap<String, Value> {
35        &self.json_files
36    }
37
38    /// Load decomposed JSON files from a [`CatalogIndex`] file table.
39    #[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    /// Load decomposed JSON from a survivor/catalog dict (`json` array entries).
55    #[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    /// Overlay another catalog's JSON files (later keys win).
81    pub fn merge_json_files(&mut self, other: &Self) {
82        self.json_files.extend(other.json_files.clone());
83    }
84
85    /// Resolve a survivor or absolute path to a stored decomposed key, if present.
86    #[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    /// Whether a decomposed JSON file exists under `key`.
99    #[must_use]
100    pub fn has_json(&self, key: &str) -> bool {
101        self.json_files.contains_key(key)
102    }
103
104    /// Borrow parsed JSON for a decomposed file key.
105    #[must_use]
106    pub fn get_json(&self, key: &str) -> Option<&Value> {
107        self.json_files.get(key)
108    }
109}
110
111/// Parse a host catalog value into [`DecomposedCatalog`] (index dict or json-files map).
112#[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/// Recursively merge JSON objects; non-object overrides replace the base value.
133#[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/// Walk parent decomposed JSON files and deep-merge them over `leaf_path`.
155#[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, &current);
190        }
191        current_path = parent_dir;
192    }
193    current
194}
195
196/// Collect rerank scores keyed by markdown content or json `file_path`.
197#[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
230/// Parse a JSON number or numeric string (pruner snapshots often store scores as strings).
231fn 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/// List input `file_path` values from pruner/rerank survivor data.
272#[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/// Parse survivor data into input file paths and score map.
293#[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
321/// Prune and sort JSON-schema `enum` arrays using rerank scores and preserve sets.
322pub 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/// Group decomposed file paths by root tool key; track standalone tool JSON files.
375#[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/// Build retrieve ``ProcessGroupsOptions`` from policy context and catalog state.
422#[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/// Enum-preservation and optional-tool pruning settings for [`process_groups`].
461#[derive(Debug, Clone, Default)]
462pub struct ProcessGroupsOptions {
463    /// Enum values that must survive pruning for system tools.
464    pub system_preserve: Option<HashSet<String>>,
465    /// Enum values that must survive pruning for MCP tools.
466    pub mcp_preserve: Option<HashSet<String>>,
467    /// Per-tool required enum values from catalog metadata.
468    pub required_by_tool: HashMap<String, HashSet<String>>,
469    /// Tool names where `effective_policy` == "`prune_optional`" (enum filtering applies).
470    pub prune_optional_tools: HashSet<String>,
471}
472
473/// Build [`ProcessGroupsOptions`] from optional policy fields (Python/Node FFI).
474#[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/// Merge grouped decomposed files into final tool schema values.
500#[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/// Options for [`retrieve_core`] and [`retrieve_tools_from_catalog`].
570#[derive(Debug, Clone, Default)]
571pub struct RetrieveOptions {
572    /// Drop low-score decomposed json entries before grouping.
573    pub apply_decomposed_score_filter: bool,
574    /// Enum preservation and optional-tool pruning for merged schemas.
575    pub process_groups: ProcessGroupsOptions,
576}
577
578/// Resolve the full build catalog dict used for reinstatement and enum metadata.
579pub 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}
592/// Returns mitigated `{json, md}` data and a survivor overlay whose chunk contents
593/// match the reinstated entries (stripped descriptions on pruned optionals).
594pub 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
622/// High-level retrieve: description reinstatement (when configured) then merge.
623pub 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
634/// Merge survivor input into the catalog store and emit reconstructed tool schemas.
635pub 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/// Options for [`removed_chunks`].
651#[derive(Debug, Clone, Default)]
652pub struct RemovedChunksOptions {
653    /// When true, json entries in `surviving` with score <= decomposed threshold are treated
654    /// as non-surviving (matches [`RetrieveOptions::apply_decomposed_score_filter`]).
655    pub apply_decomposed_score_filter: bool,
656}
657
658/// Normalized identity for a catalog chunk entry (`json` or `md` array item).
659#[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/// Chunks present in `full_catalog` but not in `surviving` (same `{json, md}` shape as survivors).
724#[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
739/// Walk a directory tree and build a `{json, md}` catalog dict from decomposed files.
740///
741/// # Errors
742///
743/// Returns an error when `dir_path` is not a directory, or when a json file cannot be read or parsed.
744pub 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}