1use std::collections::BTreeMap;
18
19use crate::workflow::{VarType, Variable};
20
21#[derive(Debug, Clone)]
23pub struct ParameterSuggestion {
24 pub original_value: String,
26 pub suggested_name: String,
28 pub description: String,
30 pub category: DetectedCategory,
32 pub confidence: f64,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Hash)]
38pub enum DetectedCategory {
39 Url,
40 FilePath,
41 ApiKey,
42 Email,
43 Port,
44 Domain,
45 GitRepo,
46 DockerImage,
47 IpAddress,
48 DatabaseUrl,
49 EnvVar,
50 UserSpecific,
52}
53
54impl DetectedCategory {
55 pub fn label(&self) -> &'static str {
56 match self {
57 Self::Url => "URL",
58 Self::FilePath => "File path",
59 Self::ApiKey => "API key/token",
60 Self::Email => "Email",
61 Self::Port => "Port",
62 Self::Domain => "Domain",
63 Self::GitRepo => "Git repository",
64 Self::DockerImage => "Docker image",
65 Self::IpAddress => "IP address",
66 Self::DatabaseUrl => "Database URL",
67 Self::EnvVar => "Environment variable",
68 Self::UserSpecific => "User-specific value",
69 }
70 }
71}
72
73pub fn detect_parameterizable_values(texts: &[&str]) -> Vec<ParameterSuggestion> {
78 let mut suggestions = Vec::new();
79 let mut seen_values: BTreeMap<String, String> = BTreeMap::new(); for text in texts {
82 detect_urls(text, &mut suggestions, &mut seen_values);
83 detect_file_paths(text, &mut suggestions, &mut seen_values);
84 detect_api_keys(text, &mut suggestions, &mut seen_values);
85 detect_emails(text, &mut suggestions, &mut seen_values);
86 detect_ports(text, &mut suggestions, &mut seen_values);
87 detect_ip_addresses(text, &mut suggestions, &mut seen_values);
88 detect_database_urls(text, &mut suggestions, &mut seen_values);
89 detect_docker_images(text, &mut suggestions, &mut seen_values);
90 detect_git_repos(text, &mut suggestions, &mut seen_values);
91 detect_user_specific(text, &mut suggestions, &mut seen_values);
92 }
93
94 suggestions.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap());
96 let mut deduped = Vec::new();
97 let mut seen = std::collections::HashSet::new();
98 for s in suggestions {
99 if seen.insert(s.original_value.clone()) {
100 deduped.push(s);
101 }
102 }
103 deduped
104}
105
106pub fn suggestions_to_variables(suggestions: &[ParameterSuggestion]) -> Vec<Variable> {
108 let mut vars = Vec::new();
109 let mut used_names = std::collections::HashSet::new();
110
111 for s in suggestions {
112 let name = if used_names.contains(&s.suggested_name) {
113 let mut n = s.suggested_name.clone();
115 let mut i = 2;
116 while used_names.contains(&n) {
117 n = format!("{}_{}", s.suggested_name, i);
118 i += 1;
119 }
120 n
121 } else {
122 s.suggested_name.clone()
123 };
124 used_names.insert(name.clone());
125
126 vars.push(Variable {
127 name,
128 var_type: VarType::String,
129 required: s.category != DetectedCategory::Port,
130 default: Some(s.original_value.clone()),
131 description: Some(s.description.clone()),
132 choices: vec![],
133 });
134 }
135 vars
136}
137
138pub fn apply_parameterization(text: &str, suggestions: &[ParameterSuggestion]) -> String {
140 let mut result = text.to_string();
141 let mut sorted: Vec<_> = suggestions.iter().collect();
143 sorted.sort_by_key(|s| std::cmp::Reverse(s.original_value.len()));
144
145 for s in sorted {
146 result = result.replace(&s.original_value, &format!("{{{{{}}}}}", s.suggested_name));
147 }
148 result
149}
150
151pub fn format_suggestions_display(suggestions: &[ParameterSuggestion]) -> String {
153 if suggestions.is_empty() {
154 return String::from(" No parameterizable values detected.");
155 }
156
157 let mut out = String::new();
158 for (i, s) in suggestions.iter().enumerate() {
159 out.push_str(&format!(
160 " {}. [{}] \"{}\" → {{{{{}}}}}\n",
161 i + 1,
162 s.category.label(),
163 truncate_display(&s.original_value, 50),
164 s.suggested_name,
165 ));
166 out.push_str(&format!(" {}\n", s.description));
167 }
168 out
169}
170
171fn truncate_display(s: &str, max: usize) -> String {
172 if s.len() <= max {
173 s.to_string()
174 } else {
175 format!("{}…", &s[..max])
176 }
177}
178
179fn add_suggestion(
182 suggestions: &mut Vec<ParameterSuggestion>,
183 seen: &mut BTreeMap<String, String>,
184 value: &str,
185 name: &str,
186 desc: &str,
187 category: DetectedCategory,
188 confidence: f64,
189) {
190 if seen.contains_key(value) {
191 return;
192 }
193 seen.insert(value.to_string(), name.to_string());
194 suggestions.push(ParameterSuggestion {
195 original_value: value.to_string(),
196 suggested_name: name.to_string(),
197 description: desc.to_string(),
198 category,
199 confidence,
200 });
201}
202
203fn detect_urls(
205 text: &str,
206 suggestions: &mut Vec<ParameterSuggestion>,
207 seen: &mut BTreeMap<String, String>,
208) {
209 let mut i = 0;
211 let bytes = text.as_bytes();
212 while i < bytes.len() {
213 if text[i..].starts_with("http://") || text[i..].starts_with("https://") {
214 let start = i;
215 while i < bytes.len() && !b" \t\n\r\"'`,;)}>]".contains(&bytes[i]) {
217 i += 1;
218 }
219 let url = &text[start..i];
220
221 if url.contains("github.com/rust-lang")
223 || url.contains("docs.rs")
224 || url.contains("crates.io")
225 || url.len() < 12
226 {
227 continue;
228 }
229
230 let name = classify_url(url);
231 let desc = format!("{} detected in workflow", url_category_desc(&name));
232 add_suggestion(
233 suggestions,
234 seen,
235 url,
236 &name,
237 &desc,
238 DetectedCategory::Url,
239 0.9,
240 );
241 } else {
242 i += 1;
243 }
244 }
245}
246
247fn classify_url(url: &str) -> String {
249 let lower = url.to_lowercase();
250 if lower.contains("/api/")
251 || lower.contains("/v1/")
252 || lower.contains("/v2/")
253 || lower.contains("/graphql")
254 {
255 "api_url".to_string()
256 } else if lower.contains("localhost") || lower.contains("127.0.0.1") {
257 "local_url".to_string()
258 } else if lower.contains(".git") || lower.contains("github.com") || lower.contains("gitlab.com")
259 {
260 "repo_url".to_string()
261 } else if lower.contains("docker") || lower.contains("registry") {
262 "registry_url".to_string()
263 } else if lower.contains("database")
264 || lower.contains("postgres")
265 || lower.contains("mysql")
266 || lower.contains("mongo")
267 {
268 "db_url".to_string()
269 } else {
270 "base_url".to_string()
271 }
272}
273
274fn url_category_desc(name: &str) -> &str {
275 match name {
276 "api_url" => "API endpoint URL",
277 "local_url" => "Local development URL",
278 "repo_url" => "Git repository URL",
279 "registry_url" => "Container registry URL",
280 "db_url" => "Database connection URL",
281 _ => "Base URL",
282 }
283}
284
285fn detect_file_paths(
287 text: &str,
288 suggestions: &mut Vec<ParameterSuggestion>,
289 seen: &mut BTreeMap<String, String>,
290) {
291 for word in text.split_whitespace() {
293 let word = word.trim_matches(|c: char| c == '"' || c == '\'' || c == ',' || c == ';');
294
295 if word.starts_with("/Users/") || word.starts_with("/home/") {
296 let parts: Vec<&str> = word.split('/').collect();
298 if parts.len() >= 4 {
299 let name = if word.contains("/Projects/")
300 || word.contains("/project")
301 || word.contains("/src/")
302 {
303 "project_dir"
304 } else if word.contains("/output")
305 || word.contains("/dist/")
306 || word.contains("/build/")
307 {
308 "output_dir"
309 } else {
310 "target_path"
311 };
312 add_suggestion(
313 suggestions,
314 seen,
315 word,
316 name,
317 "Absolute file path (user-specific, should be parameterized)",
318 DetectedCategory::FilePath,
319 0.95,
320 );
321 }
322 } else if word.starts_with("~/") && word.len() > 3 {
323 add_suggestion(
324 suggestions,
325 seen,
326 word,
327 "target_path",
328 "Home-relative path (may differ across machines)",
329 DetectedCategory::FilePath,
330 0.7,
331 );
332 } else if word.starts_with("/tmp/") || word.starts_with("/var/") {
333 add_suggestion(
334 suggestions,
335 seen,
336 word,
337 "temp_path",
338 "Temporary/system path",
339 DetectedCategory::FilePath,
340 0.6,
341 );
342 }
343 }
344}
345
346fn detect_api_keys(
348 text: &str,
349 suggestions: &mut Vec<ParameterSuggestion>,
350 seen: &mut BTreeMap<String, String>,
351) {
352 let key_prefixes = [
354 ("sk-", "api_key", "API secret key"),
355 ("sk_live_", "stripe_key", "Stripe live API key"),
356 ("sk_test_", "stripe_test_key", "Stripe test API key"),
357 ("pk_live_", "stripe_pub_key", "Stripe publishable key"),
358 ("ghp_", "github_token", "GitHub personal access token"),
359 ("gho_", "github_oauth_token", "GitHub OAuth token"),
360 ("ghs_", "github_server_token", "GitHub server token"),
361 ("glpat-", "gitlab_token", "GitLab personal access token"),
362 ("xoxb-", "slack_bot_token", "Slack bot token"),
363 ("xoxp-", "slack_user_token", "Slack user token"),
364 ("AKIA", "aws_access_key", "AWS access key ID"),
365 ("Bearer ", "auth_token", "Bearer authentication token"),
366 ("token ", "auth_token", "Authentication token"),
367 ];
368
369 for token in text.split_whitespace() {
370 let token = token.trim_matches(|c: char| c == '"' || c == '\'' || c == ',' || c == ';');
371 let word = if let Some(pos) = token.find('=') {
373 &token[pos + 1..]
374 } else {
375 token
376 };
377 for (prefix, name, desc) in &key_prefixes {
378 if word.starts_with(prefix) && word.len() > prefix.len() + 4 {
379 add_suggestion(
380 suggestions,
381 seen,
382 word,
383 name,
384 desc,
385 DetectedCategory::ApiKey,
386 1.0,
387 );
388 break;
389 }
390 }
391
392 if word.starts_with('$') && word.len() > 2 {
394 let var_name = word.trim_start_matches('$');
395 let lower = var_name.to_lowercase();
396 if lower.contains("key")
397 || lower.contains("token")
398 || lower.contains("secret")
399 || lower.contains("password")
400 || lower.contains("api")
401 {
402 let suggested = lower.replace('-', "_");
403 add_suggestion(
404 suggestions,
405 seen,
406 word,
407 &suggested,
408 &format!("Environment variable reference: {}", var_name),
409 DetectedCategory::EnvVar,
410 0.8,
411 );
412 }
413 }
414 }
415
416 for word in text.split_whitespace() {
418 let word = word.trim_matches(|c: char| c == '"' || c == '\'' || c == ',' || c == ';');
419 if word.len() >= 32
420 && word.chars().all(|c| c.is_ascii_hexdigit())
421 && !seen.contains_key(word)
422 {
423 add_suggestion(
424 suggestions,
425 seen,
426 word,
427 "auth_token",
428 "Long hex string (likely a token or hash)",
429 DetectedCategory::ApiKey,
430 0.7,
431 );
432 }
433 }
434}
435
436fn detect_emails(
438 text: &str,
439 suggestions: &mut Vec<ParameterSuggestion>,
440 seen: &mut BTreeMap<String, String>,
441) {
442 for word in text.split_whitespace() {
443 let word = word.trim_matches(|c: char| {
444 c == '"' || c == '\'' || c == ',' || c == ';' || c == '<' || c == '>'
445 });
446 if word.starts_with("git@") {
448 continue;
449 }
450 if word.contains('@') && word.contains('.') && word.len() > 5 {
451 let parts: Vec<&str> = word.split('@').collect();
453 if parts.len() == 2 && !parts[0].is_empty() && parts[1].contains('.') {
454 add_suggestion(
455 suggestions,
456 seen,
457 word,
458 "email",
459 "Email address",
460 DetectedCategory::Email,
461 0.85,
462 );
463 }
464 }
465 }
466}
467
468fn detect_ports(
470 text: &str,
471 suggestions: &mut Vec<ParameterSuggestion>,
472 seen: &mut BTreeMap<String, String>,
473) {
474 let mut i = 0;
476 let chars: Vec<char> = text.chars().collect();
477 while i < chars.len() {
478 if chars[i] == ':' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit() {
479 let start = i + 1;
480 let mut end = start;
481 while end < chars.len() && chars[end].is_ascii_digit() {
482 end += 1;
483 }
484 let port_str: String = chars[start..end].iter().collect();
485 if let Ok(port) = port_str.parse::<u16>()
486 && (1024..=65535).contains(&port)
487 && !seen.contains_key(&port_str)
488 {
489 let before: String = chars[..i]
491 .iter()
492 .rev()
493 .take(20)
494 .collect::<String>()
495 .chars()
496 .rev()
497 .collect();
498 if before.contains("localhost")
499 || before.contains("0.0.0.0")
500 || before.contains("127.0.0.1")
501 || before.ends_with("://")
502 || before
503 .chars()
504 .last()
505 .is_some_and(|c| c.is_alphanumeric() || c == '.')
506 {
507 add_suggestion(
508 suggestions,
509 seen,
510 &port_str,
511 "port",
512 &format!("Port number ({})", port),
513 DetectedCategory::Port,
514 0.6,
515 );
516 }
517 }
518 i = end;
519 } else {
520 i += 1;
521 }
522 }
523}
524
525fn detect_ip_addresses(
527 text: &str,
528 suggestions: &mut Vec<ParameterSuggestion>,
529 seen: &mut BTreeMap<String, String>,
530) {
531 for word in text.split_whitespace() {
533 let word = word.trim_matches(|c: char| !c.is_ascii_digit() && c != '.');
534 let parts: Vec<&str> = word.split('.').collect();
535 if parts.len() == 4 && parts.iter().all(|p| p.parse::<u8>().is_ok()) {
536 if word == "127.0.0.1" || word == "0.0.0.0" {
538 continue;
539 }
540 add_suggestion(
541 suggestions,
542 seen,
543 word,
544 "ip_address",
545 "IP address (environment-specific)",
546 DetectedCategory::IpAddress,
547 0.8,
548 );
549 }
550 }
551}
552
553fn detect_database_urls(
555 text: &str,
556 suggestions: &mut Vec<ParameterSuggestion>,
557 seen: &mut BTreeMap<String, String>,
558) {
559 let db_prefixes = [
560 "postgres://",
561 "postgresql://",
562 "mysql://",
563 "mongodb://",
564 "mongodb+srv://",
565 "redis://",
566 "sqlite://",
567 ];
568 for token in text.split_whitespace() {
569 let token = token.trim_matches(|c: char| c == '"' || c == '\'');
570 let word = if let Some(pos) = token.find('=') {
572 &token[pos + 1..]
573 } else {
574 token
575 };
576 for prefix in &db_prefixes {
577 if word.starts_with(prefix) {
578 add_suggestion(
579 suggestions,
580 seen,
581 word,
582 "database_url",
583 "Database connection URL (contains credentials)",
584 DetectedCategory::DatabaseUrl,
585 1.0,
586 );
587 break;
588 }
589 }
590 }
591}
592
593fn detect_docker_images(
595 text: &str,
596 suggestions: &mut Vec<ParameterSuggestion>,
597 seen: &mut BTreeMap<String, String>,
598) {
599 let docker_indicators = ["docker pull", "docker run", "docker push", "FROM "];
601 for indicator in &docker_indicators {
602 if let Some(pos) = text.find(indicator) {
603 let rest = &text[pos + indicator.len()..];
604 let image: String = rest
605 .trim_start()
606 .chars()
607 .take_while(|c| {
608 c.is_alphanumeric()
609 || *c == '/'
610 || *c == ':'
611 || *c == '.'
612 || *c == '-'
613 || *c == '_'
614 })
615 .collect();
616 if !image.is_empty() && image.len() > 3 {
617 add_suggestion(
618 suggestions,
619 seen,
620 &image,
621 "docker_image",
622 "Docker image reference",
623 DetectedCategory::DockerImage,
624 0.85,
625 );
626 }
627 }
628 }
629}
630
631fn detect_git_repos(
633 text: &str,
634 suggestions: &mut Vec<ParameterSuggestion>,
635 seen: &mut BTreeMap<String, String>,
636) {
637 for word in text.split_whitespace() {
639 let word = word.trim_matches(|c: char| c == '"' || c == '\'');
640 if word.starts_with("git@") && word.contains(':') && word.contains('/') {
641 add_suggestion(
642 suggestions,
643 seen,
644 word,
645 "repo_url",
646 "Git SSH repository URL",
647 DetectedCategory::GitRepo,
648 0.9,
649 );
650 }
651 }
652}
653
654fn detect_user_specific(
656 text: &str,
657 suggestions: &mut Vec<ParameterSuggestion>,
658 seen: &mut BTreeMap<String, String>,
659) {
660 if let Some(home) = dirs::home_dir() {
662 let home_str = home.to_string_lossy().to_string();
663 if text.contains(&home_str) && !seen.contains_key(&home_str) {
664 add_suggestion(
665 suggestions,
666 seen,
667 &home_str,
668 "home_dir",
669 "User home directory (machine-specific)",
670 DetectedCategory::UserSpecific,
671 0.95,
672 );
673 }
674 }
675
676 if let Ok(user) = std::env::var("USER")
678 && user.len() >= 3
679 {
680 let user_in_path = format!("/Users/{}", user);
681 let user_in_home = format!("/home/{}", user);
682 for pattern in [&user_in_path, &user_in_home] {
683 if text.contains(pattern.as_str()) && !seen.contains_key(pattern.as_str()) {
684 }
686 }
687 }
688}
689
690pub fn scan_workflow(workflow: &crate::workflow::Workflow) -> Vec<ParameterSuggestion> {
696 let content_text = workflow.base.content.as_text();
697 let mut texts: Vec<&str> = Vec::new();
698
699 texts.push(workflow.base.description.as_str());
700 texts.push(content_text.as_ref());
701
702 for step in &workflow.steps {
703 texts.push(step.description.as_str());
704 if let Some(ref cmd) = step.command {
705 texts.push(cmd.as_str());
706 }
707 }
708
709 detect_parameterizable_values(&texts)
710}
711
712pub fn parameterize_workflow(
714 workflow: &mut crate::workflow::Workflow,
715 suggestions: &[ParameterSuggestion],
716) {
717 if suggestions.is_empty() {
718 return;
719 }
720
721 workflow.base.description = apply_parameterization(&workflow.base.description, suggestions);
723
724 let new_content = apply_parameterization(&workflow.base.content.as_text(), suggestions);
726 workflow.base.content = crate::pattern::Content::Plain(new_content);
727
728 for step in &mut workflow.steps {
730 step.description = apply_parameterization(&step.description, suggestions);
731 if let Some(ref cmd) = step.command {
732 step.command = Some(apply_parameterization(cmd, suggestions));
733 }
734 }
735
736 let existing_names: std::collections::HashSet<String> =
738 workflow.variables.iter().map(|v| v.name.clone()).collect();
739 let new_vars = suggestions_to_variables(suggestions);
740 for var in new_vars {
741 if !existing_names.contains(&var.name) {
742 workflow.variables.push(var);
743 }
744 }
745}
746
747#[cfg(test)]
750mod tests {
751 use super::*;
752
753 #[test]
754 fn test_detect_urls() {
755 let texts = vec!["Deploy to https://api.example.com/v1/deploy"];
756 let suggestions = detect_parameterizable_values(&texts);
757 assert!(!suggestions.is_empty());
758 assert_eq!(suggestions[0].suggested_name, "api_url");
759 assert_eq!(suggestions[0].category, DetectedCategory::Url);
760 }
761
762 #[test]
763 fn test_detect_file_paths() {
764 let texts = vec!["Run build in /Users/david/Projects/myapp"];
765 let suggestions = detect_parameterizable_values(&texts);
766 assert!(
767 suggestions
768 .iter()
769 .any(|s| s.category == DetectedCategory::FilePath)
770 );
771 }
772
773 #[test]
774 fn test_detect_api_keys() {
775 let texts = vec!["Use key sk-1234567890abcdef to authenticate"];
776 let suggestions = detect_parameterizable_values(&texts);
777 assert!(
778 suggestions
779 .iter()
780 .any(|s| s.category == DetectedCategory::ApiKey)
781 );
782 assert_eq!(
783 suggestions
784 .iter()
785 .find(|s| s.category == DetectedCategory::ApiKey)
786 .unwrap()
787 .suggested_name,
788 "api_key"
789 );
790 }
791
792 #[test]
793 fn test_detect_github_token() {
794 let texts = vec!["export GITHUB_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz012345"];
795 let suggestions = detect_parameterizable_values(&texts);
796 assert!(
797 suggestions
798 .iter()
799 .any(|s| s.suggested_name == "github_token")
800 );
801 }
802
803 #[test]
804 fn test_detect_email() {
805 let texts = vec!["Send notification to admin@company.com"];
806 let suggestions = detect_parameterizable_values(&texts);
807 assert!(
808 suggestions
809 .iter()
810 .any(|s| s.category == DetectedCategory::Email)
811 );
812 }
813
814 #[test]
815 fn test_detect_database_url() {
816 let texts = vec!["DATABASE_URL=postgres://user:pass@db.example.com:5432/mydb"];
817 let suggestions = detect_parameterizable_values(&texts);
818 assert!(
819 suggestions
820 .iter()
821 .any(|s| s.category == DetectedCategory::DatabaseUrl)
822 );
823 }
824
825 #[test]
826 fn test_detect_git_ssh() {
827 let texts = vec!["git clone git@github.com:user/repo.git"];
828 let suggestions = detect_parameterizable_values(&texts);
829 assert!(
830 suggestions
831 .iter()
832 .any(|s| s.category == DetectedCategory::GitRepo)
833 );
834 }
835
836 #[test]
837 fn test_apply_parameterization() {
838 let suggestions = vec![ParameterSuggestion {
839 original_value: "https://api.example.com".to_string(),
840 suggested_name: "api_url".to_string(),
841 description: "API URL".to_string(),
842 category: DetectedCategory::Url,
843 confidence: 0.9,
844 }];
845 let result = apply_parameterization("Deploy to https://api.example.com/v1", &suggestions);
846 assert_eq!(result, "Deploy to {{api_url}}/v1");
847 }
848
849 #[test]
850 fn test_no_false_positives_on_normal_text() {
851 let texts = vec!["Run cargo build and then cargo test"];
852 let suggestions = detect_parameterizable_values(&texts);
853 assert!(suggestions.is_empty());
854 }
855
856 #[test]
857 fn test_deduplication() {
858 let texts = vec![
859 "Deploy to https://api.example.com",
860 "Also check https://api.example.com/health",
861 ];
862 let suggestions = detect_parameterizable_values(&texts);
863 let url_count = suggestions
865 .iter()
866 .filter(|s| s.category == DetectedCategory::Url)
867 .count();
868 assert!(url_count <= 2); }
870
871 #[test]
872 fn test_format_display() {
873 let suggestions = vec![ParameterSuggestion {
874 original_value: "https://api.example.com".to_string(),
875 suggested_name: "api_url".to_string(),
876 description: "API endpoint URL".to_string(),
877 category: DetectedCategory::Url,
878 confidence: 0.9,
879 }];
880 let display = format_suggestions_display(&suggestions);
881 assert!(display.contains("api_url"));
882 assert!(display.contains("URL"));
883 }
884}