Skip to main content

apcore_toolkit/
resolve_target.rs

1// Target string resolution utilities.
2//
3// Validates and parses target strings in `module::path:qualname` format.
4// In Python and TypeScript this dynamically imports and resolves the target;
5// in Rust (no runtime import) we validate the format and return the
6// parsed components.
7
8use std::sync::LazyLock;
9
10use regex::Regex;
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14/// A parsed target reference with module path and qualified name components.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct ResolvedTarget {
17    /// The module path portion (before the last `:`).
18    pub module_path: String,
19    /// The qualified name / export name (after the last `:`).
20    pub qualname: String,
21}
22
23/// Validate and parse a target string in `module_path:qualname` format.
24///
25/// The last `:` in the string is used as the separator, matching the
26/// TypeScript implementation which supports prefixed module paths.
27///
28/// # Format
29///
30/// - Python style: `"my_package.my_module:MyClass"`
31/// - TypeScript style: `"./handlers/task:createTask"`
32/// - Rust style: `"my_crate::module:function_name"`
33///
34/// # Errors
35///
36/// Returns `Err` if:
37/// - The target string contains no `:` separator
38/// - The module path is empty
39/// - The qualname is empty
40/// - The module path or qualname contain invalid characters
41///
42/// # Examples
43///
44/// ```
45/// use apcore_toolkit::resolve_target::resolve_target;
46///
47/// let result = resolve_target("my_module:my_func").unwrap();
48/// assert_eq!(result.module_path, "my_module");
49/// assert_eq!(result.qualname, "my_func");
50/// ```
51/// Errors returned by [`resolve_target`].
52#[derive(Debug, Error)]
53pub enum ResolveTargetError {
54    #[error("Invalid target format: \"{target}\". Expected \"module_path:qualname\".")]
55    MissingSeparator { target: String },
56
57    #[error("Invalid target format: \"{target}\". Module path is empty.")]
58    EmptyModulePath { target: String },
59
60    #[error("Invalid target format: \"{target}\". Qualified name is empty.")]
61    EmptyQualname { target: String },
62
63    #[error("Invalid qualname \"{qualname}\" in target \"{target}\". Must be a valid identifier.")]
64    InvalidQualname { target: String, qualname: String },
65
66    #[error("Invalid module path \"{module_path}\" in target \"{target}\": {reason}.")]
67    InvalidModulePath {
68        target: String,
69        module_path: String,
70        reason: String,
71    },
72}
73
74/// Maximum allowed length for the module_path component.
75const MAX_MODULE_PATH_LEN: usize = 512;
76
77/// Regex matching valid identifier qualnames (alphanumeric + underscores, no leading digit).
78static IDENT_RE: LazyLock<Regex> =
79    LazyLock::new(|| Regex::new(r"^[a-zA-Z_][a-zA-Z0-9_]*$").expect("static regex"));
80
81/// Validate the module path component for dangerous content.
82fn validate_module_path(module_path: &str, target: &str) -> Result<(), ResolveTargetError> {
83    if module_path.len() > MAX_MODULE_PATH_LEN {
84        return Err(ResolveTargetError::InvalidModulePath {
85            target: target.to_string(),
86            module_path: module_path.to_string(),
87            reason: format!("exceeds maximum length of {MAX_MODULE_PATH_LEN}"),
88        });
89    }
90    if module_path
91        .bytes()
92        .any(|b| b == 0 || (b < 0x20 && b != b'\t'))
93    {
94        return Err(ResolveTargetError::InvalidModulePath {
95            target: target.to_string(),
96            module_path: module_path.to_string(),
97            reason: "contains invalid control characters".to_string(),
98        });
99    }
100    if module_path.contains("..") {
101        return Err(ResolveTargetError::InvalidModulePath {
102            target: target.to_string(),
103            module_path: module_path.to_string(),
104            reason: "contains '..' (path traversal)".to_string(),
105        });
106    }
107    Ok(())
108}
109
110pub fn resolve_target(target: &str) -> Result<ResolvedTarget, ResolveTargetError> {
111    let last_colon = target
112        .rfind(':')
113        .ok_or_else(|| ResolveTargetError::MissingSeparator {
114            target: target.to_string(),
115        })?;
116
117    let module_path = &target[..last_colon];
118    let qualname = &target[last_colon + 1..];
119
120    if module_path.is_empty() {
121        return Err(ResolveTargetError::EmptyModulePath {
122            target: target.to_string(),
123        });
124    }
125
126    if qualname.is_empty() {
127        return Err(ResolveTargetError::EmptyQualname {
128            target: target.to_string(),
129        });
130    }
131
132    validate_module_path(module_path, target)?;
133
134    if !IDENT_RE.is_match(qualname) {
135        return Err(ResolveTargetError::InvalidQualname {
136            target: target.to_string(),
137            qualname: qualname.to_string(),
138        });
139    }
140
141    Ok(ResolvedTarget {
142        module_path: module_path.to_string(),
143        qualname: qualname.to_string(),
144    })
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_resolve_target_python_style() {
153        let result = resolve_target("my_package.my_module:MyClass").unwrap();
154        assert_eq!(result.module_path, "my_package.my_module");
155        assert_eq!(result.qualname, "MyClass");
156    }
157
158    #[test]
159    fn test_resolve_target_rust_style() {
160        let result = resolve_target("my_crate::handlers::task:create_task").unwrap();
161        assert_eq!(result.module_path, "my_crate::handlers::task");
162        assert_eq!(result.qualname, "create_task");
163    }
164
165    #[test]
166    fn test_resolve_target_simple() {
167        let result = resolve_target("app:handler").unwrap();
168        assert_eq!(result.module_path, "app");
169        assert_eq!(result.qualname, "handler");
170    }
171
172    #[test]
173    fn test_resolve_target_typescript_style() {
174        let result = resolve_target("./handlers/task:createTask").unwrap();
175        assert_eq!(result.module_path, "./handlers/task");
176        assert_eq!(result.qualname, "createTask");
177    }
178
179    #[test]
180    fn test_resolve_target_no_colon() {
181        let result = resolve_target("no_colon_here");
182        assert!(result.is_err());
183        assert!(result
184            .unwrap_err()
185            .to_string()
186            .contains("Invalid target format"));
187    }
188
189    #[test]
190    fn test_resolve_target_empty_module() {
191        let result = resolve_target(":qualname");
192        assert!(result.is_err());
193        assert!(result
194            .unwrap_err()
195            .to_string()
196            .contains("Module path is empty"));
197    }
198
199    #[test]
200    fn test_resolve_target_empty_qualname() {
201        let result = resolve_target("module:");
202        assert!(result.is_err());
203        assert!(result
204            .unwrap_err()
205            .to_string()
206            .contains("Qualified name is empty"));
207    }
208
209    #[test]
210    fn test_resolve_target_invalid_qualname() {
211        let result = resolve_target("module:123invalid");
212        assert!(result.is_err());
213        assert!(result
214            .unwrap_err()
215            .to_string()
216            .contains("Must be a valid identifier"));
217    }
218
219    #[test]
220    fn test_resolve_target_module_path_with_null_byte() {
221        let result = resolve_target("mod\x00path:func");
222        assert!(result.is_err());
223        let msg = result.unwrap_err().to_string();
224        assert!(msg.contains("control characters"), "{msg}");
225    }
226
227    #[test]
228    fn test_resolve_target_module_path_too_long() {
229        let long_path = "a".repeat(513);
230        let result = resolve_target(&format!("{long_path}:func"));
231        assert!(result.is_err());
232        let msg = result.unwrap_err().to_string();
233        assert!(msg.contains("maximum length"), "{msg}");
234    }
235
236    #[test]
237    fn test_resolve_target_module_path_with_dotdot() {
238        let result = resolve_target("../../../etc/passwd:func");
239        assert!(result.is_err());
240        let msg = result.unwrap_err().to_string();
241        assert!(msg.contains("path traversal"), "{msg}");
242    }
243
244    #[test]
245    fn test_resolve_target_module_path_dotdot_in_middle() {
246        let result = resolve_target("a/b/../c:func");
247        assert!(result.is_err());
248    }
249
250    #[test]
251    fn test_resolve_target_qualname_with_spaces() {
252        let result = resolve_target("module:has spaces");
253        assert!(result.is_err());
254    }
255
256    #[test]
257    fn test_resolve_target_node_prefix() {
258        // TypeScript-style node: prefix — last colon is the separator
259        let result = resolve_target("node:path:join").unwrap();
260        assert_eq!(result.module_path, "node:path");
261        assert_eq!(result.qualname, "join");
262    }
263
264    #[test]
265    fn test_resolve_target_underscore_qualname() {
266        let result = resolve_target("mod:_private_func").unwrap();
267        assert_eq!(result.qualname, "_private_func");
268    }
269
270    #[test]
271    fn test_resolved_target_serde_roundtrip() {
272        let target = ResolvedTarget {
273            module_path: "my_crate::handlers".into(),
274            qualname: "create_task".into(),
275        };
276        let json = serde_json::to_string(&target).unwrap();
277        let deserialized: ResolvedTarget = serde_json::from_str(&json).unwrap();
278        assert_eq!(deserialized, target);
279    }
280
281    #[test]
282    fn test_ident_re_not_recompiled_per_call() {
283        // Calling resolve_target twice exercises the LazyLock path (no recompile).
284        let r1 = resolve_target("a:valid_func").unwrap();
285        let r2 = resolve_target("b:another_func").unwrap();
286        assert_eq!(r1.qualname, "valid_func");
287        assert_eq!(r2.qualname, "another_func");
288    }
289}