1use std::path::Path;
2
3use anyhow::Result;
4use lsp_types::SymbolKind;
5use regex::Regex;
6use serde_json::{Map, Value};
7
8use crate::{
9 config::{normalize_path, ResolvedConfig},
10 discovery::RepoDiscovery,
11 ids::document_id,
12 lsp::{line_contains, range_end_line, range_start_line, LspClient, SymbolLocation},
13 model::ArtifactDoc,
14 security::apply_artifact_security,
15};
16
17fn has_segment(path: &str, segment: &str) -> bool {
18 path.starts_with(&format!("{segment}/")) || path.contains(&format!("/{segment}/"))
19}
20
21fn line_number(text: &str, offset: usize) -> u32 {
22 text[..offset].bytes().filter(|byte| *byte == b'\n').count() as u32 + 1
23}
24
25fn new_doc(
26 config: &ResolvedConfig,
27 path: &Path,
28 kind: &str,
29 name: &str,
30 line: u32,
31 side: &str,
32) -> ArtifactDoc {
33 let source_path = normalize_path(&config.root, path);
34 ArtifactDoc {
35 id: document_id(
36 &config.repo,
37 kind,
38 Some(&source_path),
39 Some(line),
40 Some(name),
41 ),
42 repo: config.repo.clone(),
43 kind: kind.to_owned(),
44 side: Some(side.to_owned()),
45 language: Some("rust".to_owned()),
46 name: Some(name.to_owned()),
47 display_name: Some(name.to_owned()),
48 source_path: Some(source_path),
49 line_start: Some(line),
50 line_end: Some(line),
51 column_start: None,
52 column_end: None,
53 package_name: None,
54 comments: Vec::new(),
55 tags: Vec::new(),
56 related_symbols: Vec::new(),
57 related_tests: Vec::new(),
58 risk_level: "low".to_owned(),
59 risk_reasons: Vec::new(),
60 contains_phi: false,
61 has_related_tests: false,
62 updated_at: chrono::Utc::now().to_rfc3339(),
63 data: Map::new(),
64 }
65}
66
67pub fn extract(config: &ResolvedConfig, discovery: &RepoDiscovery) -> Result<Vec<ArtifactDoc>> {
68 let mut artifacts = Vec::new();
69 let command_re = Regex::new(r#"(?s)#\[(?:tauri::)?command\]\s*(?:pub\s+)?(async\s+)?fn\s+([A-Za-z0-9_]+)(?:<[^>]+>)?\s*(\([^)]*\))"#)
70 .expect("valid regex");
71 let command_attr_re = Regex::new(r#"(?m)^\s*#\[(?:tauri::)?command\]"#).expect("valid regex");
72 let builder_re = Regex::new(r#"Builder::new\("([^"]+)"\)"#).expect("valid regex");
73 let hook_re = Regex::new(r#"\.(setup|on_navigation|on_webview_ready|on_event|on_drop)\("#)
74 .expect("valid regex");
75 let permission_re = Regex::new(r#"identifier\s*=\s*"([^"]+)""#).expect("valid regex");
76 let commands_allow_re =
77 Regex::new(r#"commands\.allow\s*=\s*\[([^\]]+)\]"#).expect("valid regex");
78 let mut rust_lsp = LspClient::new("rust-analyzer", &config.root).ok();
79
80 for path in discovery
81 .rust_files
82 .iter()
83 .chain(discovery.plugin_rust_files.iter())
84 {
85 let text = std::fs::read_to_string(path)?;
86 let normalized = normalize_path(&config.root, path);
87 let symbol_locations = rust_lsp
88 .as_mut()
89 .and_then(|client| client.document_symbols(path, &text, "rust").ok())
90 .unwrap_or_default();
91 let plugin_name = if has_segment(&normalized, "plugins") {
92 builder_re
93 .captures(&text)
94 .and_then(|capture| capture.get(1))
95 .map(|item| item.as_str().to_owned())
96 .or_else(|| {
97 normalized
98 .strip_prefix("plugins/")
99 .or_else(|| normalized.split("/plugins/").nth(1))
100 .and_then(|tail| tail.split('/').next())
101 .map(|item| item.trim_start_matches("tauri-plugin-").to_owned())
102 })
103 } else {
104 None
105 };
106
107 if let Some(plugin_name) = &plugin_name {
108 let line = builder_re
109 .captures(&text)
110 .and_then(|capture| capture.get(0))
111 .map(|item| line_number(&text, item.start()))
112 .unwrap_or(1);
113 let mut plugin_doc = new_doc(config, path, "tauri_plugin", plugin_name, line, "rust");
114 plugin_doc
115 .data
116 .insert("plugin_name".to_owned(), Value::String(plugin_name.clone()));
117 apply_artifact_security(&mut plugin_doc);
118 artifacts.push(plugin_doc);
119
120 for capture in hook_re.captures_iter(&text) {
121 let hook_name = capture.get(1).expect("hook").as_str();
122 let line = line_number(&text, capture.get(0).expect("match").start());
123 let mut hook_doc = new_doc(
124 config,
125 path,
126 "tauri_plugin_lifecycle_hook",
127 hook_name,
128 line,
129 "rust",
130 );
131 hook_doc
132 .data
133 .insert("plugin_name".to_owned(), Value::String(plugin_name.clone()));
134 hook_doc
135 .data
136 .insert("hook_name".to_owned(), Value::String(hook_name.to_owned()));
137 apply_artifact_security(&mut hook_doc);
138 artifacts.push(hook_doc);
139 }
140 }
141
142 let mut lsp_command_docs = build_lsp_command_docs(
143 config,
144 path,
145 &normalized,
146 &text,
147 &symbol_locations,
148 &command_attr_re,
149 plugin_name.as_deref(),
150 );
151
152 if lsp_command_docs.is_empty() {
153 lsp_command_docs = command_re
154 .captures_iter(&text)
155 .map(|capture| {
156 let full = capture.get(0).expect("match");
157 let name = capture.get(2).expect("name").as_str();
158 let signature = capture
159 .get(3)
160 .map(|item| item.as_str().to_owned())
161 .unwrap_or_default();
162 let line = line_number(&text, full.start());
163 let kind = if plugin_name.is_some() {
164 "tauri_plugin_command"
165 } else {
166 "tauri_command"
167 };
168 let mut doc = new_doc(config, path, kind, name, line, "rust");
169 doc.display_name = Some(name.to_owned());
170 doc.tags = vec!["rust command".to_owned()];
171 doc.data
172 .insert("signature".to_owned(), Value::String(signature.clone()));
173 doc.data.insert(
174 "rust_fqn".to_owned(),
175 Value::String(format!(
176 "{}::{name}",
177 normalized.replace('/', "::").trim_end_matches(".rs")
178 )),
179 );
180 if let Some(plugin_name) = &plugin_name {
181 doc.data
182 .insert("plugin_name".to_owned(), Value::String(plugin_name.clone()));
183 doc.data.insert(
184 "invoke_key".to_owned(),
185 Value::String(format!("plugin:{plugin_name}|{name}")),
186 );
187 } else {
188 doc.data
189 .insert("invoke_key".to_owned(), Value::String(name.to_owned()));
190 }
191 let registered = text.contains("generate_handler!") && text.contains(name);
192 doc.data
193 .insert("registered".to_owned(), Value::Bool(registered));
194 apply_artifact_security(&mut doc);
195 doc
196 })
197 .collect();
198 }
199
200 artifacts.extend(lsp_command_docs);
201 }
202
203 for path in &discovery.permission_files {
204 let text = std::fs::read_to_string(path)?;
205 let normalized = normalize_path(&config.root, path);
206 if let Some(capture) = permission_re.captures(&text) {
207 let name = capture.get(1).expect("identifier").as_str();
208 let line = line_number(&text, capture.get(0).expect("match").start());
209 let mut doc = new_doc(config, path, "tauri_permission", name, line, "config");
210 let plugin_name = normalized
211 .strip_prefix("plugins/")
212 .or_else(|| normalized.split("/plugins/").nth(1))
213 .and_then(|tail| tail.split('/').next())
214 .map(|item| item.trim_start_matches("tauri-plugin-").to_owned());
215 if let Some(plugin_name) = plugin_name {
216 doc.data
217 .insert("plugin_name".to_owned(), Value::String(plugin_name.clone()));
218 doc.name = Some(format!("{plugin_name}:{name}"));
219 doc.display_name = doc.name.clone();
220 }
221 if let Some(allow_capture) = commands_allow_re.captures(&text) {
222 let commands = allow_capture[1]
223 .split(',')
224 .map(|item| item.trim().trim_matches('"').to_owned())
225 .filter(|item| !item.is_empty())
226 .collect::<Vec<_>>();
227 doc.data.insert(
228 "commands_allow".to_owned(),
229 Value::Array(commands.into_iter().map(Value::String).collect()),
230 );
231 }
232 apply_artifact_security(&mut doc);
233 let permission_name = doc.name.clone().unwrap_or_else(|| name.to_owned());
234 artifacts.push(doc);
235
236 let mut scope_doc =
237 new_doc(config, path, "tauri_permission_scope", name, line, "config");
238 scope_doc
239 .data
240 .insert("permission_id".to_owned(), Value::String(permission_name));
241 apply_artifact_security(&mut scope_doc);
242 artifacts.push(scope_doc);
243 }
244 }
245
246 let rust_test_targets_re =
247 Regex::new(r#"async\s+fn\s+([A-Za-z0-9_]+)|fn\s+([A-Za-z0-9_]+)"#).expect("valid regex");
248
249 for path in &discovery.rust_test_files {
250 let text = std::fs::read_to_string(path)?;
251 let normalized = normalize_path(&config.root, path);
252 let name = Path::new(&normalized)
253 .file_name()
254 .and_then(|item| item.to_str())
255 .unwrap_or("rust_test");
256 let mut doc = new_doc(config, path, "rust_test", name, 1, "test");
257 let targets = rust_test_targets_re
258 .captures_iter(&text)
259 .filter_map(|capture| capture.get(1).or_else(|| capture.get(2)))
260 .map(|item| item.as_str().to_owned())
261 .collect::<Vec<_>>();
262 doc.data.insert(
263 "targets".to_owned(),
264 Value::Array(targets.into_iter().map(Value::String).collect()),
265 );
266 doc.data.insert(
267 "command".to_owned(),
268 Value::String(format!("cargo test {}", normalize_path(&config.root, path))),
269 );
270 apply_artifact_security(&mut doc);
271 artifacts.push(doc);
272 }
273
274 Ok(artifacts)
275}
276
277fn build_lsp_command_docs(
278 config: &ResolvedConfig,
279 path: &Path,
280 normalized: &str,
281 text: &str,
282 symbols: &[SymbolLocation],
283 command_attr_re: &Regex,
284 plugin_name: Option<&str>,
285) -> Vec<ArtifactDoc> {
286 let function_symbols = symbols
287 .iter()
288 .filter(|symbol| matches!(symbol.kind, SymbolKind::FUNCTION | SymbolKind::METHOD))
289 .collect::<Vec<_>>();
290
291 let mut docs = Vec::new();
292 for capture in command_attr_re.find_iter(text) {
293 let attr_line = line_number(text, capture.start());
294 let Some(symbol) = match_command_symbol(&function_symbols, attr_line) else {
295 continue;
296 };
297
298 let kind = if plugin_name.is_some() {
299 "tauri_plugin_command"
300 } else {
301 "tauri_command"
302 };
303 let mut doc = new_doc(
304 config,
305 path,
306 kind,
307 &symbol.name,
308 range_start_line(&symbol.range),
309 "rust",
310 );
311 doc.display_name = Some(symbol.name.clone());
312 doc.tags = vec!["rust command".to_owned()];
313 doc.line_end = Some(range_end_line(&symbol.range));
314 doc.data.insert(
315 "signature".to_owned(),
316 Value::String(extract_signature(text, &symbol.name)),
317 );
318 doc.data.insert(
319 "rust_fqn".to_owned(),
320 Value::String(format!(
321 "{}::{}",
322 normalized.replace('/', "::").trim_end_matches(".rs"),
323 symbol.name
324 )),
325 );
326 if let Some(plugin_name) = plugin_name {
327 doc.data.insert(
328 "plugin_name".to_owned(),
329 Value::String(plugin_name.to_owned()),
330 );
331 doc.data.insert(
332 "invoke_key".to_owned(),
333 Value::String(format!("plugin:{plugin_name}|{}", symbol.name)),
334 );
335 } else {
336 doc.data
337 .insert("invoke_key".to_owned(), Value::String(symbol.name.clone()));
338 }
339 let registered = text.contains("generate_handler!") && text.contains(&symbol.name);
340 doc.data
341 .insert("registered".to_owned(), Value::Bool(registered));
342 doc.data.insert(
343 "source_map_backend".to_owned(),
344 Value::String("rust-analyzer-lsp".to_owned()),
345 );
346 apply_artifact_security(&mut doc);
347 docs.push(doc);
348 }
349 docs
350}
351
352fn match_command_symbol<'a>(
353 symbols: &'a [&'a SymbolLocation],
354 attr_line: u32,
355) -> Option<&'a SymbolLocation> {
356 symbols
357 .iter()
358 .copied()
359 .find(|symbol| line_contains(&symbol.range, attr_line))
360 .or_else(|| {
361 symbols
362 .iter()
363 .copied()
364 .filter(|symbol| range_start_line(&symbol.range) >= attr_line)
365 .min_by_key(|symbol| range_start_line(&symbol.range) - attr_line)
366 })
367}
368
369fn extract_signature(text: &str, function_name: &str) -> String {
370 let pattern = format!(
371 r#"(?m)(?:pub\s+)?(?:async\s+)?fn\s+{}\b(?:<[^>]+>)?\s*(\([^)]*\))"#,
372 regex::escape(function_name)
373 );
374 Regex::new(&pattern)
375 .ok()
376 .and_then(|regex| regex.captures(text))
377 .and_then(|capture| capture.get(1))
378 .map(|capture| capture.as_str().to_owned())
379 .unwrap_or_default()
380}