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 regex::Regex;
9
10/// A parsed target reference with module path and qualified name components.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ResolvedTarget {
13    /// The module path portion (before the last `:`).
14    pub module_path: String,
15    /// The qualified name / export name (after the last `:`).
16    pub qualname: String,
17}
18
19/// Validate and parse a target string in `module_path:qualname` format.
20///
21/// The last `:` in the string is used as the separator, matching the
22/// TypeScript implementation which supports prefixed module paths.
23///
24/// # Format
25///
26/// - Python style: `"my_package.my_module:MyClass"`
27/// - TypeScript style: `"./handlers/task:createTask"`
28/// - Rust style: `"my_crate::module:function_name"`
29///
30/// # Errors
31///
32/// Returns `Err` if:
33/// - The target string contains no `:` separator
34/// - The module path is empty
35/// - The qualname is empty
36/// - The module path or qualname contain invalid characters
37///
38/// # Examples
39///
40/// ```
41/// use apcore_toolkit::resolve_target::resolve_target;
42///
43/// let result = resolve_target("my_module:my_func").unwrap();
44/// assert_eq!(result.module_path, "my_module");
45/// assert_eq!(result.qualname, "my_func");
46/// ```
47pub fn resolve_target(target: &str) -> Result<ResolvedTarget, String> {
48    let last_colon = target.rfind(':').ok_or_else(|| {
49        format!("Invalid target format: \"{target}\". Expected \"module_path:qualname\".")
50    })?;
51
52    let module_path = &target[..last_colon];
53    let qualname = &target[last_colon + 1..];
54
55    if module_path.is_empty() {
56        return Err(format!(
57            "Invalid target format: \"{target}\". Module path is empty."
58        ));
59    }
60
61    if qualname.is_empty() {
62        return Err(format!(
63            "Invalid target format: \"{target}\". Qualified name is empty."
64        ));
65    }
66
67    // Validate qualname: must be a valid identifier (alphanumeric + underscores)
68    let ident_re = Regex::new(r"^[a-zA-Z_][a-zA-Z0-9_]*$").unwrap();
69    if !ident_re.is_match(qualname) {
70        return Err(format!(
71            "Invalid qualname \"{qualname}\" in target \"{target}\". \
72             Must be a valid identifier."
73        ));
74    }
75
76    Ok(ResolvedTarget {
77        module_path: module_path.to_string(),
78        qualname: qualname.to_string(),
79    })
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn test_resolve_target_python_style() {
88        let result = resolve_target("my_package.my_module:MyClass").unwrap();
89        assert_eq!(result.module_path, "my_package.my_module");
90        assert_eq!(result.qualname, "MyClass");
91    }
92
93    #[test]
94    fn test_resolve_target_rust_style() {
95        let result = resolve_target("my_crate::handlers::task:create_task").unwrap();
96        assert_eq!(result.module_path, "my_crate::handlers::task");
97        assert_eq!(result.qualname, "create_task");
98    }
99
100    #[test]
101    fn test_resolve_target_simple() {
102        let result = resolve_target("app:handler").unwrap();
103        assert_eq!(result.module_path, "app");
104        assert_eq!(result.qualname, "handler");
105    }
106
107    #[test]
108    fn test_resolve_target_typescript_style() {
109        let result = resolve_target("./handlers/task:createTask").unwrap();
110        assert_eq!(result.module_path, "./handlers/task");
111        assert_eq!(result.qualname, "createTask");
112    }
113
114    #[test]
115    fn test_resolve_target_no_colon() {
116        let result = resolve_target("no_colon_here");
117        assert!(result.is_err());
118        assert!(result.unwrap_err().contains("Invalid target format"));
119    }
120
121    #[test]
122    fn test_resolve_target_empty_module() {
123        let result = resolve_target(":qualname");
124        assert!(result.is_err());
125        assert!(result.unwrap_err().contains("Module path is empty"));
126    }
127
128    #[test]
129    fn test_resolve_target_empty_qualname() {
130        let result = resolve_target("module:");
131        assert!(result.is_err());
132        assert!(result.unwrap_err().contains("Qualified name is empty"));
133    }
134
135    #[test]
136    fn test_resolve_target_invalid_qualname() {
137        let result = resolve_target("module:123invalid");
138        assert!(result.is_err());
139        assert!(result.unwrap_err().contains("Must be a valid identifier"));
140    }
141
142    #[test]
143    fn test_resolve_target_qualname_with_spaces() {
144        let result = resolve_target("module:has spaces");
145        assert!(result.is_err());
146    }
147
148    #[test]
149    fn test_resolve_target_node_prefix() {
150        // TypeScript-style node: prefix — last colon is the separator
151        let result = resolve_target("node:path:join").unwrap();
152        assert_eq!(result.module_path, "node:path");
153        assert_eq!(result.qualname, "join");
154    }
155
156    #[test]
157    fn test_resolve_target_underscore_qualname() {
158        let result = resolve_target("mod:_private_func").unwrap();
159        assert_eq!(result.qualname, "_private_func");
160    }
161}