anymd 8.4.0

anymd: any file to clean Markdown for AI agents. Pure-Rust MCP server + CLI binary for @sylphx/anymd
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
//! Typed MCP tool argument schemas (schemars -> tools/list inputSchema).
//!
//! This module intentionally mirrors the immutable TypeScript v3.0.14 schemas.
//! `schemars` produces the client-visible contract; the `validate` methods close
//! the gap between descriptive JSON Schema and serde's structural decoding.

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

pub const MAX_SOURCES_PER_REQUEST: usize = 32;

fn option_bool_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
    // Keep Option semantics (nullable) while guaranteeing a boolean type keyword for tools/list.
    serde_json::from_value(serde_json::json!({
        "type": ["boolean", "null"]
    }))
    .expect("option bool schema")
}

fn option_revisions_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
    serde_json::from_value(serde_json::json!({
        "type": ["string", "null"],
        "enum": ["markup", "accept", "reject", null]
    }))
    .expect("option revisions schema")
}

fn option_images_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
    serde_json::from_value(serde_json::json!({
        "type": ["string", "null"],
        "enum": ["refs", "none", null]
    }))
    .expect("option images schema")
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(transparent)]
pub struct PageNumber(#[schemars(range(min = 1))] pub u32);

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum PageSpecifier {
    Pages(Vec<PageNumber>),
    Range(#[schemars(length(min = 1))] String),
}

impl PageSpecifier {
    fn validate(&self) -> Result<(), String> {
        match self {
            Self::Pages(pages) if pages.iter().any(|page| page.0 == 0) => {
                Err("sources[].pages entries must be integers greater than or equal to 1.".into())
            }
            Self::Range(range) if range.is_empty() => {
                Err("sources[].pages range string must not be empty.".into())
            }
            _ => Ok(()),
        }
    }
}

// Client-visible tools/list schema intentionally omits oneOf/not exclusive
// constructs: OpenCode + Fireworks (and similar strict tool-schema validators)
// reject `{"required":["path"],"not":{"required":["url"]}}` (issue #562).
// Exactly-one-of path|url remains a hard runtime contract via `validate()`.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct PdfSource {
    #[schemars(
        length(min = 1),
        description = "Path to the local PDF file (absolute or relative to cwd). Provide exactly one of path or url (not both)."
    )]
    pub path: Option<String>,
    #[schemars(
        length(min = 1),
        description = "Explicit source URL. Provide exactly one of path or url (not both)."
    )]
    pub url: Option<String>,
    pub pages: Option<PageSpecifier>,
}

impl PdfSource {
    pub fn validate(&self) -> Result<(), String> {
        let has_path = self.path.as_ref().is_some_and(|value| !value.is_empty());
        let has_url = self.url.as_ref().is_some_and(|value| !value.is_empty());
        match (has_path, has_url) {
            (true, false) | (false, true) => {}
            (false, false) | (true, true) => {
                return Err("Provide exactly one of path or url for each PDF source.".into());
            }
        }
        if let Some(pages) = &self.pages {
            pages.validate()?;
        }
        Ok(())
    }

    pub fn label(&self) -> String {
        self.path
            .clone()
            .or_else(|| self.url.clone())
            .unwrap_or_else(|| "unknown".into())
    }
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ReadPdfAutoDetail {
    Fast,
    Balanced,
    Full,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TrustReportRedaction {
    Standard,
    Strict,
    Off,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default)]
pub struct ReadPdfArgs {
    pub sources: Vec<PdfSource>,
    #[schemars(
        range(min = 500),
        description = "Token budget for the Markdown answer (default 20000). Longer documents stop at a page boundary and end with a cursor to continue."
    )]
    pub max_tokens: Option<u32>,
    #[schemars(
        description = "Continue a previous read: pass the cursor from its last line (\"<page>\" or \"<page>:<offset>\"). Single source only."
    )]
    pub cursor: Option<String>,
    #[schemars(
        description = "Legacy preset switch. true selects balanced (fast plus safety, trust, and accessibility) when profile and auto_detail are omitted. Omit it to use fast. false keeps manual control. auto_detail wins over profile. Never enables OCR."
    )]
    pub auto: Option<bool>,
    #[schemars(
        description = "Depth switch that wins over profile: fast, balanced, or full. full adds structure and audits. Never enables OCR or rendering."
    )]
    pub auto_detail: Option<ReadPdfAutoDetail>,
    #[schemars(
        description = "Named preset: fast (default), quality (structure, no audits, no OCR), or research (quality plus safety, trust, and accessibility). Ignored when auto is false or any include_* flag is set."
    )]
    pub profile: Option<String>,
    #[schemars(
        range(min = 1, max = 20),
        description = "Not used by read_pdf presets, which read every requested page. Sampling belongs to pdf_evidence inspect. Limit a read with sources[].pages."
    )]
    pub sample_pages: Option<u32>,
    pub include_full_text: Option<bool>,
    pub include_metadata: Option<bool>,
    pub include_page_count: Option<bool>,
    pub include_images: Option<bool>,
    pub include_tables: Option<bool>,
    pub include_elements: Option<bool>,
    pub include_semantic_hints: Option<bool>,
    pub include_markdown: Option<bool>,
    pub include_html: Option<bool>,
    pub include_chunks: Option<bool>,
    pub include_text_layer: Option<bool>,
    pub include_ocr_text_layer: Option<bool>,
    pub include_outline: Option<bool>,
    pub include_annotations: Option<bool>,
    pub include_page_labels: Option<bool>,
    pub include_page_geometry: Option<bool>,
    pub include_permissions: Option<bool>,
    pub include_form_fields: Option<bool>,
    pub include_attachments: Option<bool>,
    pub include_structure_tree: Option<bool>,
    pub include_safety_findings: Option<bool>,
    pub include_layout_diagnostics: Option<bool>,
    pub include_document_map: Option<bool>,
    pub include_document_ast: Option<bool>,
    pub include_visual_enrichments: Option<bool>,
    #[schemars(range(min = 1))]
    pub max_visual_enrichments: Option<u32>,
    pub include_trust_report: Option<bool>,
    pub trust_report_redaction: Option<TrustReportRedaction>,
    pub include_accessibility_report: Option<bool>,
}

impl ReadPdfArgs {
    pub fn validate(&self) -> Result<(), String> {
        validate_source_count(&self.sources)?;
        for source in &self.sources {
            source.validate()?;
        }
        validate_u32_range("sample_pages", self.sample_pages, 1, 20)?;
        validate_u32_min("max_tokens", self.max_tokens, 500)?;
        validate_u32_min("max_visual_enrichments", self.max_visual_enrichments, 1)
    }
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ComparePdfArgs {
    #[schemars(
        length(min = 1),
        description = "Local PDF path for the before document."
    )]
    pub before: String,
    #[schemars(
        length(min = 1),
        description = "Local PDF path for the after document."
    )]
    pub after: String,
    #[schemars(range(min = 1))]
    pub max_file_bytes: Option<u64>,
    #[schemars(range(min = 0, max = 1000))]
    pub context_chars: Option<u32>,
}

impl ComparePdfArgs {
    pub fn validate(&self) -> Result<(), String> {
        if self.before.trim().is_empty() || self.after.trim().is_empty() {
            return Err("before and after are required".into());
        }
        if self.before == self.after {
            return Err("before and after must be different PDFs".into());
        }
        if self.context_chars.is_some_and(|value| value > 1000) {
            return Err("context_chars must be <= 1000".into());
        }
        Ok(())
    }
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct SearchPdfArgs {
    pub sources: Vec<PdfSource>,
    #[schemars(
        length(min = 1),
        description = "Literal text query to search for in extracted PDF text."
    )]
    pub query: String,
    pub case_sensitive: Option<bool>,
    pub whole_word: Option<bool>,
    pub include_ocr_text_layer: Option<bool>,
    #[schemars(range(min = 1, max = 1000))]
    pub max_pages: Option<u32>,
    #[schemars(range(min = 1, max = 500))]
    pub max_matches_per_source: Option<u32>,
    #[schemars(range(min = 0, max = 1000))]
    pub context_chars: Option<u32>,
    /// When true, match the current TS prefer_speed route: omit match geometry and
    /// emit the rust-text-index speed-route warning (ignored when OCR is enabled).
    #[schemars(
        description = "Prefer faster text-index search (omit match geometry; ignored when OCR is enabled).",
        schema_with = "option_bool_schema"
    )]
    pub prefer_speed: Option<bool>,
    #[schemars(
        description = "Return the detailed JSON result with match geometry and provenance instead of the compact Markdown list.",
        schema_with = "option_bool_schema"
    )]
    pub detail: Option<bool>,
}

impl SearchPdfArgs {
    pub fn validate(&self) -> Result<(), String> {
        validate_source_count(&self.sources)?;
        for source in &self.sources {
            source.validate()?;
        }
        if self.query.is_empty() {
            return Err("query must not be empty.".into());
        }
        validate_u32_range("max_pages", self.max_pages, 1, 1000)?;
        validate_u32_range(
            "max_matches_per_source",
            self.max_matches_per_source,
            1,
            500,
        )?;
        validate_u32_range("context_chars", self.context_chars, 0, 1000)
    }
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct RegionBoundingBox {
    #[schemars(range(min = 0.0))]
    pub left: f64,
    #[schemars(range(min = 0.0))]
    pub bottom: f64,
    #[schemars(range(min = 0.0))]
    pub right: f64,
    #[schemars(range(min = 0.0))]
    pub top: f64,
}

impl RegionBoundingBox {
    fn validate(&self) -> Result<(), String> {
        for (name, value) in [
            ("left", self.left),
            ("bottom", self.bottom),
            ("right", self.right),
            ("top", self.top),
        ] {
            if !value.is_finite() || value < 0.0 {
                return Err(format!(
                    "sources[].regions[].bounding_box.{name} must be at least 0."
                ));
            }
        }
        Ok(())
    }
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct PdfEvidenceRegion {
    pub id: Option<String>,
    #[schemars(range(min = 1))]
    pub page: u32,
    pub bounding_box: RegionBoundingBox,
    #[schemars(range(min = 0.0, max = 200.0))]
    pub padding: Option<f64>,
}

impl PdfEvidenceRegion {
    fn validate(&self) -> Result<(), String> {
        validate_u32_min("sources[].regions[].page", Some(self.page), 1)?;
        self.bounding_box.validate()?;
        if let Some(padding) = self.padding {
            if !padding.is_finite() || !(0.0..=200.0).contains(&padding) {
                return Err("sources[].regions[].padding must be between 0 and 200.".into());
            }
        }
        Ok(())
    }
}

// Same provider-compat policy as PdfSource (#562): no oneOf/not in tools/list;
// exclusive path|url enforced at runtime by validate()/as_pdf_source().
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct PdfEvidenceSource {
    #[schemars(
        length(min = 1),
        description = "Path to a local source (PDF for PDF operations; media for video operations). Provide exactly one of path or url (not both)."
    )]
    pub path: Option<String>,
    #[schemars(
        length(min = 1),
        description = "Explicit source URL. Provide exactly one of path or url (not both)."
    )]
    pub url: Option<String>,
    pub pages: Option<PageSpecifier>,
    pub regions: Option<Vec<PdfEvidenceRegion>>,
}

impl PdfEvidenceSource {
    pub fn validate(&self) -> Result<(), String> {
        let source = self.as_pdf_source();
        source.validate()?;
        if let Some(regions) = &self.regions {
            for region in regions {
                region.validate()?;
            }
        }
        Ok(())
    }

    pub fn label(&self) -> String {
        self.path
            .clone()
            .or_else(|| self.url.clone())
            .unwrap_or_else(|| "unknown".into())
    }

    pub fn as_pdf_source(&self) -> PdfSource {
        PdfSource {
            path: self.path.clone(),
            url: self.url.clone(),
            pages: self.pages.clone(),
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PdfEvidenceOperation {
    Inspect,
    RenderPage,
    ExtractRegions,
    OcrPages,
    AnalyzeRegions,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct PdfEvidenceArgs {
    pub operation: PdfEvidenceOperation,
    pub sources: Vec<PdfEvidenceSource>,
    #[schemars(range(min = 1, max = 20))]
    pub sample_pages: Option<u32>,
    pub include_metadata: Option<bool>,
    #[schemars(range(min = 0.25, max = 4.0))]
    pub scale: Option<f64>,
    #[schemars(range(min = 1, max = 20))]
    pub max_pages: Option<u32>,
    #[schemars(range(min = 1, max = 100))]
    pub max_regions: Option<u32>,
    #[schemars(range(min = 10_000, max = 64_000_000))]
    pub max_pixels_per_page: Option<u64>,
    pub include_image: Option<bool>,
    #[schemars(range(min = 1_000, max = 300_000))]
    pub timeout_ms: Option<u32>,
    #[schemars(range(min = 1_000, max = 1_000_000))]
    pub max_output_chars: Option<u32>,
    pub languages: Option<Vec<String>>,
    #[schemars(
        description = "Local OCR engine for ocr_pages: auto, vlm, or tesseract. Omit to use the configured command provider."
    )]
    pub ocr: Option<crate::ocr_vlm::OcrEngine>,
}

impl PdfEvidenceArgs {
    pub fn validate(&self) -> Result<(), String> {
        validate_source_count(&self.sources)?;
        for source in &self.sources {
            source.validate()?;
        }
        validate_u32_range("sample_pages", self.sample_pages, 1, 20)?;
        validate_f64_range("scale", self.scale, 0.25, 4.0)?;
        validate_u32_range("max_pages", self.max_pages, 1, 20)?;
        validate_u32_range("max_regions", self.max_regions, 1, 100)?;
        validate_u64_range(
            "max_pixels_per_page",
            self.max_pixels_per_page,
            10_000,
            64_000_000,
        )?;
        validate_u32_range("timeout_ms", self.timeout_ms, 1_000, 300_000)?;
        validate_u32_range("max_output_chars", self.max_output_chars, 1_000, 1_000_000)
    }
}

fn validate_source_count<T>(sources: &[T]) -> Result<(), String> {
    if sources.len() > MAX_SOURCES_PER_REQUEST {
        Err(format!(
            "Request accepts at most {MAX_SOURCES_PER_REQUEST} sources."
        ))
    } else {
        Ok(())
    }
}

fn validate_u32_min(name: &str, value: Option<u32>, min: u32) -> Result<(), String> {
    if value.is_some_and(|value| value < min) {
        Err(format!("{name} must be at least {min}."))
    } else {
        Ok(())
    }
}

fn validate_u32_range(name: &str, value: Option<u32>, min: u32, max: u32) -> Result<(), String> {
    if value.is_some_and(|value| !(min..=max).contains(&value)) {
        Err(format!("{name} must be between {min} and {max}."))
    } else {
        Ok(())
    }
}

fn validate_u64_range(name: &str, value: Option<u64>, min: u64, max: u64) -> Result<(), String> {
    if value.is_some_and(|value| !(min..=max).contains(&value)) {
        Err(format!("{name} must be between {min} and {max}."))
    } else {
        Ok(())
    }
}

fn validate_f64_range(name: &str, value: Option<f64>, min: f64, max: f64) -> Result<(), String> {
    if value.is_some_and(|value| !value.is_finite() || !(min..=max).contains(&value)) {
        Err(format!("{name} must be between {min} and {max}."))
    } else {
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::{PdfEvidenceArgs, ReadPdfArgs, SearchPdfArgs};
    use serde::de::DeserializeOwned;
    use serde_json::Value;

    fn parse_and_validate<T>(value: Value, validate: impl FnOnce(&T) -> Result<(), String>) -> bool
    where
        T: DeserializeOwned,
    {
        serde_json::from_value::<T>(value)
            .ok()
            .is_some_and(|parsed| validate(&parsed).is_ok())
    }

    fn oracle() -> Value {
        serde_json::from_str(include_str!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/../../test/fixtures/legacy-tool-input-cases.json"
        )))
        .expect("parse legacy tool input cases")
    }

    fn accepts(tool: &str, args: Value) -> bool {
        match tool {
            "read_pdf" => parse_and_validate::<ReadPdfArgs>(args, ReadPdfArgs::validate),
            "search_pdf" => parse_and_validate::<SearchPdfArgs>(args, SearchPdfArgs::validate),
            "pdf_evidence" => {
                parse_and_validate::<PdfEvidenceArgs>(args, PdfEvidenceArgs::validate)
            }
            other => panic!("unknown oracle tool {other}"),
        }
    }

    #[test]
    fn immutable_v3_0_14_accept_reject_corpus_matches() {
        let oracle = oracle();
        assert_eq!(oracle["tag"], "v3.0.14");
        assert_eq!(oracle["commit"], "92651c79c6ce8d10dfa3c76332176c26f222bd78");
        for case in oracle["cases"].as_array().expect("oracle cases") {
            let id = case["id"].as_str().expect("case id");
            let tool = case["tool"].as_str().expect("case tool");
            let expected = case["accept"].as_bool().expect("case acceptance");
            let actual = accepts(tool, case["args"].clone());
            assert_eq!(actual, expected, "v3.0.14 contract case {id}");
        }
    }
}

#[cfg(test)]
mod provider_schema_compat_tests {
    use super::*;
    use schemars::schema_for;

    #[test]
    fn read_node_keeps_its_own_description() {
        let schema = serde_json::to_value(schema_for!(ReadArgs)).unwrap();
        let properties = &schema["properties"];
        let node = properties["node"]["description"].as_str().unwrap_or("");
        assert!(
            node.contains("section id returned by outline"),
            "{properties}"
        );
        let timeline = properties["timeline"]["description"].as_str().unwrap_or("");
        assert!(timeline.contains("video"), "{properties}");
        assert!(!timeline.contains("section id returned by outline"));
    }
    #[test]
    fn pdf_source_schema_omits_not_required_xor() {
        let schema = schema_for!(PdfSource);
        let json = serde_json::to_string(&schema).expect("serialize schema");
        assert!(
            !json.contains("\"not\""),
            "PdfSource tools/list schema must not emit not/required XOR for provider compat (#562): {json}"
        );
        assert!(
            !json.contains("\"oneOf\""),
            "PdfSource tools/list schema must not emit oneOf XOR for provider compat (#562): {json}"
        );
    }

    #[test]
    fn pdf_source_runtime_still_enforces_exactly_one_locator() {
        assert!(PdfSource {
            path: Some("a.pdf".into()),
            url: None,
            pages: None,
        }
        .validate()
        .is_ok());
        assert!(PdfSource {
            path: None,
            url: Some("https://example.com/a.pdf".into()),
            pages: None,
        }
        .validate()
        .is_ok());
        assert!(PdfSource {
            path: None,
            url: None,
            pages: None,
        }
        .validate()
        .is_err());
        assert!(PdfSource {
            path: Some("a.pdf".into()),
            url: Some("https://example.com/a.pdf".into()),
            pages: None,
        }
        .validate()
        .is_err());
    }
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ReadArgs {
    #[schemars(
        length(min = 1),
        description = "File path, http(s) URL, or directory. PDF, DOCX, PPTX, XLSX/XLS/ODS, CSV/TSV, EPUB, HTML, Markdown/text, images, audio/video, SRT/VTT. A directory returns the list of readable files."
    )]
    pub source: String,
    #[schemars(
        description = "Local video only: read this bounded playback window as deterministic timeline sections. end_ms is required; the window is half-open, at most ten minutes and twenty scenes."
    )]
    pub timeline: Option<crate::video_request::TimelineSelection>,
    /// Read a section id returned by outline; repeat node with a continuation cursor.
    pub node: Option<String>,
    #[schemars(
        description = "Pages (PDF), slides, sheets, or chapters to read, e.g. \"1-5,8\". Default: all."
    )]
    pub pages: Option<String>,
    #[schemars(
        range(min = 500),
        description = "Token budget (default 20000). Longer documents stop at a unit boundary and end with a cursor."
    )]
    pub max_tokens: Option<u32>,
    #[schemars(description = "Continue a previous read with the cursor from its last line.")]
    pub cursor: Option<String>,
    #[schemars(
        description = "Local OCR: auto, vlm, or tesseract. Auto uses installed doc-VLM weights (setup ocr is explicit CPU opt-in), otherwise tesseract. No automatic downloads. Boolean false disables; true retains automatic OCR."
    )]
    pub ocr: Option<crate::ocr_vlm::OcrSelection>,
    #[schemars(
        description = "Transcribe audio/video locally with bundled transcribe-cpp and Qwen3-ASR-1.7B Q8. Needs preinstalled SHA-256 pinned weights (~2.2 GB), unless download_asr_model is true. Audio stays local. Default false.",
        schema_with = "option_bool_schema"
    )]
    pub transcript: Option<bool>,
    #[schemars(
        description = "Implies transcript. Downloads pinned Qwen3-ASR-1.7B Q8 weights (~2.2 GB) into the anymd cache. Downloads only when explicitly enabled. Legacy download_whisper_model is accepted as an alias. Default false.",
        schema_with = "option_bool_schema"
    )]
    #[serde(alias = "download_whisper_model")]
    pub download_asr_model: Option<bool>,
    #[schemars(
        description = "Images embedded inside PDFs, DOCX, PPTX and EPUB files. \"refs\" (default) saves each meaningful raster image once to the anymd cache and marks its place in the Markdown as ![caption](absolute path) plus an <!-- image: WxH, page N --> comment, so you can open the file yourself; logos, icons and repeated headers are skipped. \"none\" leaves images out. Standalone image files are not affected.",
        schema_with = "option_images_schema"
    )]
    pub images: Option<String>,
    #[schemars(
        description = "Tracked changes and comments in Word (DOCX) files. \"markup\" (default) shows them as CriticMarkup: {++inserted++}, {--deleted--}, {~~old~>new~~}, {==commented==}{>>Author (date): comment<<}, each change followed by its author and date. \"accept\" gives the text with every change accepted and \"reject\" the text with every change rejected, both without comments. A document with no tracked changes or comments reads the same either way.",
        schema_with = "option_revisions_schema"
    )]
    pub revisions: Option<String>,
}

impl ReadArgs {
    /// Whether embedded images are exported (the default) or left out.
    pub fn wants_images(&self) -> bool {
        self.images.as_deref() != Some("none")
    }

    /// How Word tracked changes come out: CriticMarkup unless `accept` or
    /// `reject` was asked for.
    pub fn revisions(&self) -> anymd_formats::Revisions {
        self.revisions
            .as_deref()
            .and_then(anymd_formats::Revisions::parse)
            .unwrap_or_default()
    }

    pub fn validate(&self) -> Result<(), String> {
        if self.source.trim().is_empty() {
            return Err("source must not be empty.".into());
        }
        if let Some(mode) = self.revisions.as_deref() {
            if anymd_formats::Revisions::parse(mode).is_none() {
                return Err("revisions must be \"markup\", \"accept\" or \"reject\".".into());
            }
        }
        if let Some(mode) = self.images.as_deref() {
            if mode != "refs" && mode != "none" {
                return Err("images must be \"refs\" or \"none\".".into());
            }
        }
        validate_u32_min("max_tokens", self.max_tokens, 500)
    }
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct SearchArgs {
    #[schemars(length(min = 1), description = "Text to find.")]
    pub query: String,
    #[schemars(
        description = "Files, directories (searched recursively, .gitignore respected), or URLs. Default: the current directory."
    )]
    #[serde(default)]
    pub sources: Vec<String>,
    #[schemars(
        description = "auto (default): exact phrase, falling back to ranked passages when nothing matches. literal: exact phrase only. ranked: BM25 over the query words."
    )]
    pub mode: Option<String>,
    #[schemars(
        description = "Only search files matching this glob inside directories, e.g. \"*.pdf\" or \"reports/**\"."
    )]
    pub glob: Option<String>,
    #[schemars(schema_with = "option_bool_schema")]
    pub case_sensitive: Option<bool>,
    #[schemars(schema_with = "option_bool_schema")]
    pub whole_word: Option<bool>,
    #[schemars(
        range(min = 1, max = 500),
        description = "Maximum hits to return (default 20)."
    )]
    pub max_results: Option<u32>,
    #[schemars(
        range(min = 0, max = 1000),
        description = "Snippet context characters on each side (default 80)."
    )]
    pub context_chars: Option<u32>,
}

impl SearchArgs {
    pub fn validate(&self) -> Result<(), String> {
        if self.query.trim().is_empty() {
            return Err("query must not be empty.".into());
        }
        if self.sources.len() > 256 {
            return Err("sources accepts at most 256 entries.".into());
        }
        validate_u32_range("max_results", self.max_results, 1, 500)?;
        validate_u32_range("context_chars", self.context_chars, 0, 1000)
    }
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum InspectOperation {
    /// Bounded local video timeline.
    VideoTimeline,
    /// Decode actual video frames at requested playback timestamps.
    RenderFrame,
    /// Deterministic quote and location support, not semantic truth.
    CiteCheck,
    /// Page count, metadata, and per-page facts.
    Inspect,
    /// Render pages to PNG images.
    RenderPage,
    /// Crop regions (bounding boxes) out of rendered pages.
    ExtractRegions,
    /// OCR pages with the configured provider.
    OcrPages,
    /// Send regions to a configured vision/region provider.
    AnalyzeRegions,
    /// Structured JSON read: document map, elements, geometry, trust and accessibility reports.
    Structure,
    /// Page-level text diff between two PDFs (sources[0] = before, sources[1] = after).
    Compare,
}

#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CiteNormalization {
    #[default]
    None,
    WhitespaceV1,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CiteBoundingBox {
    pub left: f64,
    pub bottom: f64,
    pub right: f64,
    pub top: f64,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Citation {
    pub id: Option<String>,
    #[schemars(
        length(min = 1, max = 4096),
        description = "Nonempty quote, at most 4096 UTF-16 units; request total at most 64000 units."
    )]
    pub quote: String,
    #[schemars(
        range(min = 1),
        description = "One-based physical PDF page, not a printed page label. At most 20 distinct pages per request."
    )]
    pub page: u32,
    /// Bottom-left PDF coordinates, finite and with positive area. No padding.
    pub bounding_box: CiteBoundingBox,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct InspectArgs {
    #[schemars(
        length(min = 1, max = 100),
        description = "cite_check only: 1–100 quotes with required page and bounding_box; exactly one PDF source."
    )]
    pub citations: Option<Vec<Citation>>,
    pub normalization: Option<CiteNormalization>,
    pub operation: InspectOperation,
    #[schemars(
        description = "video_timeline only (required there): half-open playback window with required end_ms, at most ten minutes and twenty detected-cut scenes. caption only samples one frame per scene through a user-configured local-command adapter."
    )]
    pub timeline: Option<crate::video_request::TimelineSelection>,
    #[schemars(
        description = "render_frame only: playback timestamps in integer milliseconds (at most twenty). Returns decoded frame metadata with the actual decoded timestamp."
    )]
    pub timestamps_ms: Option<Vec<u64>>,
    #[schemars(
        length(min = 64, max = 64),
        description = "cite_check and video operations: fail if the admitted source's SHA-256 differs from this hexadecimal value."
    )]
    pub expected_source_sha256: Option<String>,
    #[schemars(
        description = "video_timeline only: also attach timed ASR cues from preinstalled local Qwen3-ASR weights. Never downloads models. Default false."
    )]
    pub transcript: Option<bool>,
    pub sources: Vec<PdfEvidenceSource>,
    #[schemars(
        description = "structure only: fast (default), quality, or research (adds safety, trust, accessibility)."
    )]
    pub profile: Option<String>,
    #[schemars(range(min = 1, max = 20))]
    pub sample_pages: Option<u32>,
    pub include_metadata: Option<bool>,
    #[schemars(range(min = 0.25, max = 4.0))]
    pub scale: Option<f64>,
    #[schemars(range(min = 1, max = 20))]
    pub max_pages: Option<u32>,
    #[schemars(range(min = 1, max = 100))]
    pub max_regions: Option<u32>,
    #[schemars(range(min = 10_000, max = 64_000_000))]
    pub max_pixels_per_page: Option<u64>,
    pub include_image: Option<bool>,
    #[schemars(range(min = 1_000, max = 300_000))]
    pub timeout_ms: Option<u32>,
    #[schemars(range(min = 1_000, max = 1_000_000))]
    pub max_output_chars: Option<u32>,
    pub languages: Option<Vec<String>>,
    #[schemars(
        description = "Local OCR engine for ocr_pages: auto, vlm, or tesseract. Omit to use the configured command provider."
    )]
    pub ocr: Option<crate::ocr_vlm::OcrEngine>,
}

/// Navigate a document without a model or vector index.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct OutlineArgs {
    #[schemars(
        description = "Local video only: outline this bounded playback window from metadata (cuts, chapters, subtitles). No ASR, OCR or captions run."
    )]
    pub timeline: Option<crate::video_request::TimelineSelection>,
    /// File path or http(s) URL.
    pub source: String,
    /// json (default) or tree.
    pub format: Option<String>,
    /// OCR image-only pages; false by default. Accepts booleans or auto/vlm/tesseract.
    pub ocr: Option<crate::ocr_vlm::OcrSelection>,
    /// Embedded images: none (default) or refs. Match this option in node reads.
    pub images: Option<String>,
    /// Word revisions: markup (default), accept or reject. Match node reads.
    pub revisions: Option<String>,
}