Skip to main content

artifactize_tools/builtin/
input.rs

1//! Parse schema-validated provider arguments once, before builtin execution.
2use crate::Builtin;
3use globset::GlobMatcher;
4use regex::{Regex, RegexBuilder};
5use serde::{Deserialize, Deserializer, de};
6use serde_json::Value;
7
8#[derive(Debug)]
9pub enum Input {
10    Read(Read),
11    List(List),
12    Glob(Glob),
13    Grep(Grep),
14    ViewImage(ViewImage),
15}
16impl Input {
17    /// CLI values enter the same compiled domain as provider input, without JSON round trips.
18    pub(crate) fn glob(pattern: &str, path: String) -> Result<Self, String> {
19        pattern_size(pattern)?;
20        Ok(Self::Glob(Glob {
21            pattern: super::glob(pattern)?,
22            path: LogicalPath::parse(path)?,
23        }))
24    }
25
26    pub(crate) fn grep(
27        pattern: &str,
28        path: String,
29        glob: Option<&str>,
30        insensitive: bool,
31    ) -> Result<Self, String> {
32        Grep::new(
33            pattern,
34            LogicalPath::parse(path)?,
35            glob,
36            insensitive,
37            super::MAX_RESULTS,
38        )
39        .map(Self::Grep)
40    }
41
42    pub fn parse(builtin: Builtin, value: Value) -> Result<Self, String> {
43        let validator = crate::schema::compile(&super::input_schema(builtin))?;
44        crate::schema::validate(&validator, &value)?;
45        let input = match builtin {
46            Builtin::Graph | Builtin::Section | Builtin::Help | Builtin::Open => {
47                return Err("This builtin requires declared args.".into());
48            }
49            Builtin::Read => serde_json::from_value(value).map(Self::Read),
50            Builtin::List => serde_json::from_value(value).map(Self::List),
51            Builtin::Glob => serde_json::from_value(value).map(Self::Glob),
52            Builtin::Grep => serde_json::from_value(value).map(Self::Grep),
53            Builtin::ViewImage => serde_json::from_value(value).map(Self::ViewImage),
54        };
55        input.map_err(|error| error.to_string())
56    }
57}
58
59#[derive(Debug, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct Read {
62    pub path: LogicalPath,
63    #[serde(default = "first_line", deserialize_with = "integer")]
64    pub offset: usize,
65    #[serde(default = "read_lines", deserialize_with = "integer")]
66    pub limit: usize,
67}
68#[derive(Debug, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct List {
71    #[serde(default)]
72    pub path: LogicalPath,
73    #[serde(default, deserialize_with = "integer")]
74    pub offset: usize,
75    #[serde(default = "max_results", deserialize_with = "integer")]
76    pub limit: usize,
77}
78#[derive(Debug, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct Glob {
81    #[serde(deserialize_with = "glob_pattern")]
82    pub pattern: GlobMatcher,
83    #[serde(default)]
84    pub path: LogicalPath,
85}
86#[derive(Debug)]
87pub struct Grep {
88    pub pattern: Regex,
89    pub path: LogicalPath,
90    pub glob: Option<GlobMatcher>,
91    pub max_results: usize,
92}
93impl<'de> Deserialize<'de> for Grep {
94    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
95        #[derive(Deserialize)]
96        #[serde(rename_all = "camelCase", deny_unknown_fields)]
97        struct Arguments {
98            pattern: String,
99            #[serde(default)]
100            path: LogicalPath,
101            #[serde(default)]
102            glob: Option<String>,
103            #[serde(default)]
104            case_insensitive: bool,
105            #[serde(default = "max_results", deserialize_with = "integer")]
106            max_results: usize,
107        }
108        let args = Arguments::deserialize(deserializer)?;
109        Self::new(
110            &args.pattern,
111            args.path,
112            args.glob.as_deref(),
113            args.case_insensitive,
114            args.max_results,
115        )
116        .map_err(de::Error::custom)
117    }
118}
119
120impl Grep {
121    fn new(
122        pattern: &str,
123        path: LogicalPath,
124        glob: Option<&str>,
125        insensitive: bool,
126        max_results: usize,
127    ) -> Result<Self, String> {
128        pattern_size(pattern)?;
129        if let Some(glob) = glob {
130            pattern_size(glob)?;
131        }
132        let pattern = RegexBuilder::new(pattern)
133            .case_insensitive(insensitive)
134            .size_limit(super::REGEX_BYTES)
135            .build()
136            .map_err(|_| "Invalid or oversized regex pattern.".to_owned())?;
137        Ok(Self {
138            pattern,
139            path,
140            glob: glob.map(super::glob).transpose()?,
141            max_results,
142        })
143    }
144}
145
146fn pattern_size(pattern: &str) -> Result<(), String> {
147    if pattern.is_empty() || pattern.chars().count() > super::MAX_PATTERN_UNITS {
148        Err("Pattern must be nonempty and fit the declared pattern limit.".into())
149    } else {
150        Ok(())
151    }
152}
153
154/// Validated logical syntax. The legacy file-root spelling is resolved against the owner
155/// by Reader::root_path; it never grants a folder access to dot components.
156#[derive(Debug, Default)]
157pub struct LogicalPath(String);
158impl LogicalPath {
159    pub fn parse(path: String) -> Result<Self, String> {
160        if path != "." {
161            crate::scope::logical_path(&path).map_err(|error| error.to_string())?;
162        }
163        Ok(Self(path))
164    }
165    pub fn as_str(&self) -> &str {
166        &self.0
167    }
168}
169impl std::ops::Deref for LogicalPath {
170    type Target = str;
171    fn deref(&self) -> &str {
172        self.as_str()
173    }
174}
175impl<'de> Deserialize<'de> for LogicalPath {
176    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
177        let path = String::deserialize(deserializer)?;
178        Self::parse(path).map_err(de::Error::custom)
179    }
180}
181
182fn glob_pattern<'de, D: Deserializer<'de>>(deserializer: D) -> Result<GlobMatcher, D::Error> {
183    super::glob(&String::deserialize(deserializer)?).map_err(de::Error::custom)
184}
185
186#[derive(Debug, Deserialize)]
187#[serde(deny_unknown_fields)]
188pub struct ViewImage {
189    pub path: LogicalPath,
190}
191
192fn first_line() -> usize {
193    1
194}
195fn read_lines() -> usize {
196    super::DEFAULT_READ_LINES
197}
198fn max_results() -> usize {
199    super::MAX_RESULTS
200}
201
202/// JSON Schema treats 1 and 1.0 as the same integer. Preserve the validator's numeric
203/// semantics and the previous saturating usize conversion, including 32-bit hosts.
204fn integer<'de, D: Deserializer<'de>>(deserializer: D) -> Result<usize, D::Error> {
205    struct Integer;
206    impl de::Visitor<'_> for Integer {
207        type Value = usize;
208        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209            formatter.write_str("a nonnegative safe JSON integer")
210        }
211        fn visit_u64<E: de::Error>(self, value: u64) -> Result<usize, E> {
212            self.visit_f64(value as f64)
213        }
214        fn visit_i64<E: de::Error>(self, value: i64) -> Result<usize, E> {
215            self.visit_f64(value as f64)
216        }
217        fn visit_f64<E: de::Error>(self, value: f64) -> Result<usize, E> {
218            if value.is_finite()
219                && value >= 0.0
220                && value.fract() == 0.0
221                && value <= crate::MAX_SAFE_JSON_INTEGER as f64
222            {
223                Ok(value as usize)
224            } else {
225                Err(E::custom("expected a nonnegative safe JSON integer"))
226            }
227        }
228    }
229    deserializer.deserialize_any(Integer)
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use serde_json::json;
236
237    #[test]
238    fn edge_rejects_out_of_range_and_unknown_arguments_without_caller_validation() {
239        for (builtin, value) in [
240            (Builtin::Read, json!({"path":"notes", "offset":0})),
241            (Builtin::Read, json!({"path":"notes", "limit":501})),
242            (Builtin::List, json!({"limit":201})),
243            (Builtin::Grep, json!({"pattern":"notes", "maxResults":0})),
244            (Builtin::Glob, json!({"pattern":""})),
245            (Builtin::Read, json!({"path":"../notes"})),
246            (Builtin::List, json!({"path":"nested//notes"})),
247            (Builtin::Glob, json!({"pattern":"../*"})),
248            (Builtin::Grep, json!({"pattern":"["})),
249            (Builtin::Grep, json!({"pattern":"notes", "glob":"../*"})),
250            (Builtin::ViewImage, json!({"path":"image", "unknown":true})),
251        ] {
252            assert!(Input::parse(builtin, value).is_err());
253        }
254        let parsed = Input::parse(
255            Builtin::Read,
256            json!({"path":"notes", "offset":1.0, "limit":2.0}),
257        )
258        .unwrap();
259        let Input::Read(input) = parsed else {
260            panic!("expected a read input")
261        };
262        assert_eq!((input.offset, input.limit), (1, 2));
263    }
264}