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::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/// In-memory map of decomposed JSON schema files keyed by catalog-relative path.
18#[derive(Debug, Clone, Default)]
19pub struct DecomposedCatalog {
20    /// Parsed JSON object per decomposed file path.
21    pub(crate) json_files: HashMap<String, Value>,
22}
23
24impl DecomposedCatalog {
25    /// Wrap a pre-built path-to-JSON map.
26    #[must_use]
27    pub const fn from_json_files(json_files: HashMap<String, Value>) -> Self {
28        Self { json_files }
29    }
30
31    /// Borrow the underlying path-to-JSON map.
32    #[must_use]
33    pub const fn json_files(&self) -> &HashMap<String, Value> {
34        &self.json_files
35    }
36
37    /// Load decomposed JSON files from a [`CatalogIndex`] file table.
38    #[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    /// Load decomposed JSON from a survivor/catalog dict (`json` array entries).
54    #[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    /// Overlay another catalog's JSON files (later keys win).
80    pub fn merge_json_files(&mut self, other: &Self) {
81        self.json_files.extend(other.json_files.clone());
82    }
83
84    /// Resolve a survivor or absolute path to a stored decomposed key, if present.
85    #[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    /// Whether a decomposed JSON file exists under `key`.
98    #[must_use]
99    pub fn has_json(&self, key: &str) -> bool {
100        self.json_files.contains_key(key)
101    }
102
103    /// Borrow parsed JSON for a decomposed file key.
104    #[must_use]
105    pub fn get_json(&self, key: &str) -> Option<&Value> {
106        self.json_files.get(key)
107    }
108}
109
110/// Parse a host catalog value into [`DecomposedCatalog`] (index dict or json-files map).
111#[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/// Recursively merge JSON objects; non-object overrides replace the base value.
132#[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/// Walk parent decomposed JSON files and deep-merge them over `leaf_path`.
154#[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, &current);
189        }
190        current_path = parent_dir;
191    }
192    current
193}
194
195/// Collect rerank scores keyed by markdown content or json `file_path`.
196#[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
229/// Parse a JSON number or numeric string (pruner snapshots often store scores as strings).
230fn 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/// List input `file_path` values from pruner/rerank survivor data.
271#[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/// Parse survivor data into input file paths and score map.
292#[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
324/// Prune and sort JSON-schema `enum` arrays using rerank scores and preserve sets.
325pub 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/// 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    #[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}