Skip to main content

mur_common/
parameterize.rs

1//! Auto-detection of parameterizable values in workflow exports.
2//!
3//! When `mur out` exports a workflow, this module scans all text content
4//! (step descriptions, commands, URLs) for values that should be replaced
5//! with `{{variable}}` templates to make the workflow reusable.
6//!
7//! ## Detected Patterns
8//! - URLs (http/https) → `{{base_url}}`, `{{api_url}}`, `{{site_url}}`
9//! - File paths (absolute) → `{{project_dir}}`, `{{output_path}}`
10//! - API tokens/keys → `{{api_key}}`, `{{auth_token}}`
11//! - Email addresses → `{{email}}`
12//! - Port numbers → `{{port}}`
13//! - Domain names → `{{domain}}`
14//! - Git repos → `{{repo_url}}`
15//! - Docker images → `{{docker_image}}`
16
17use std::collections::BTreeMap;
18
19use crate::workflow::{VarType, Variable};
20
21/// A suggestion to replace a detected value with a variable.
22#[derive(Debug, Clone)]
23pub struct ParameterSuggestion {
24    /// The original literal value found in the text
25    pub original_value: String,
26    /// Suggested variable name (e.g. "base_url", "api_key")
27    pub suggested_name: String,
28    /// Human-readable description
29    pub description: String,
30    /// The category of this detection
31    pub category: DetectedCategory,
32    /// Confidence score 0.0–1.0
33    pub confidence: f64,
34}
35
36/// Categories of auto-detected parameterizable values.
37#[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    /// User-specific value (username, home dir, etc.)
51    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
73/// Scan text content for parameterizable values and return suggestions.
74///
75/// This is the main entry point — call with all text from a workflow
76/// (concatenated step descriptions, commands, etc.).
77pub fn detect_parameterizable_values(texts: &[&str]) -> Vec<ParameterSuggestion> {
78    let mut suggestions = Vec::new();
79    let mut seen_values: BTreeMap<String, String> = BTreeMap::new(); // value → suggested_name
80
81    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    // Deduplicate by original_value
95    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
106/// Convert suggestions into workflow Variable definitions.
107pub 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            // Disambiguate with a suffix
114            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
138/// Apply variable substitutions to text — replace literal values with `{{var_name}}`.
139pub fn apply_parameterization(text: &str, suggestions: &[ParameterSuggestion]) -> String {
140    let mut result = text.to_string();
141    // Sort by length descending to avoid partial replacements
142    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
151/// Format suggestions for display in the terminal.
152pub 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
179// ─── Detectors ─────────────────────────────────────────────────────────────
180
181fn 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
203/// Detect URLs (http:// and https://)
204fn detect_urls(
205    text: &str,
206    suggestions: &mut Vec<ParameterSuggestion>,
207    seen: &mut BTreeMap<String, String>,
208) {
209    // Simple URL extraction — find http(s)://... sequences
210    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            // Advance past the URL
216            while i < bytes.len() && !b" \t\n\r\"'`,;)}>]".contains(&bytes[i]) {
217                i += 1;
218            }
219            let url = &text[start..i];
220
221            // Skip common non-parameterizable URLs
222            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
247/// Classify a URL into a suggested variable name.
248fn 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
285/// Detect absolute file paths (/Users/..., /home/..., /tmp/..., etc.)
286fn detect_file_paths(
287    text: &str,
288    suggestions: &mut Vec<ParameterSuggestion>,
289    seen: &mut BTreeMap<String, String>,
290) {
291    // Match paths like /Users/foo/bar, /home/user/project, ~/something
292    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            // User-specific absolute path
297            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
346/// Detect API keys and tokens (high-entropy strings, common prefixes).
347fn detect_api_keys(
348    text: &str,
349    suggestions: &mut Vec<ParameterSuggestion>,
350    seen: &mut BTreeMap<String, String>,
351) {
352    // Known API key prefixes
353    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        // Handle KEY=value format: also check the value part after '='
372        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        // Also detect environment variable references like $API_KEY, $SECRET
393        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    // Detect hex strings that look like tokens (32+ hex chars)
417    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
436/// Detect email addresses.
437fn 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        // Skip git SSH URLs (git@host:user/repo) — handled by detect_git_repos
447        if word.starts_with("git@") {
448            continue;
449        }
450        if word.contains('@') && word.contains('.') && word.len() > 5 {
451            // Basic email validation
452            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
468/// Detect port numbers in common patterns.
469fn detect_ports(
470    text: &str,
471    suggestions: &mut Vec<ParameterSuggestion>,
472    seen: &mut BTreeMap<String, String>,
473) {
474    // Match :PORT patterns (e.g. localhost:3000, 0.0.0.0:8080)
475    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                // Check context — is there a host before the colon?
490                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
525/// Detect IP addresses.
526fn detect_ip_addresses(
527    text: &str,
528    suggestions: &mut Vec<ParameterSuggestion>,
529    seen: &mut BTreeMap<String, String>,
530) {
531    // Simple IPv4 detection
532    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            // Skip localhost and common non-parameterizable IPs
537            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
553/// Detect database connection URLs.
554fn 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        // Handle KEY=value format: extract the value part
571        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
593/// Detect Docker image references.
594fn detect_docker_images(
595    text: &str,
596    suggestions: &mut Vec<ParameterSuggestion>,
597    seen: &mut BTreeMap<String, String>,
598) {
599    // Docker image patterns: registry/image:tag, image:tag
600    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
631/// Detect git repository URLs.
632fn detect_git_repos(
633    text: &str,
634    suggestions: &mut Vec<ParameterSuggestion>,
635    seen: &mut BTreeMap<String, String>,
636) {
637    // git@host:user/repo.git patterns
638    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
654/// Detect user-specific values (home directories, usernames in paths).
655fn detect_user_specific(
656    text: &str,
657    suggestions: &mut Vec<ParameterSuggestion>,
658    seen: &mut BTreeMap<String, String>,
659) {
660    // Detect the current user's home directory
661    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    // Detect current username in paths
677    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                // Already covered by home_dir detection usually, skip
685            }
686        }
687    }
688}
689
690// ─── Workflow-level parameterization ───────────────────────────────────────
691
692/// Scan an entire workflow for parameterizable values and return suggestions.
693///
694/// This collects text from all workflow fields: description, steps, commands.
695pub 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
712/// Apply all accepted suggestions to a workflow, replacing literal values with `{{var}}`.
713pub fn parameterize_workflow(
714    workflow: &mut crate::workflow::Workflow,
715    suggestions: &[ParameterSuggestion],
716) {
717    if suggestions.is_empty() {
718        return;
719    }
720
721    // Replace in description
722    workflow.base.description = apply_parameterization(&workflow.base.description, suggestions);
723
724    // Replace in content
725    let new_content = apply_parameterization(&workflow.base.content.as_text(), suggestions);
726    workflow.base.content = crate::pattern::Content::Plain(new_content);
727
728    // Replace in steps
729    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    // Merge suggested variables into workflow.variables (avoid duplicates)
737    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// ─── Tests ─────────────────────────────────────────────────────────────────
748
749#[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        // The URL should appear only once
864        let url_count = suggestions
865            .iter()
866            .filter(|s| s.category == DetectedCategory::Url)
867            .count();
868        assert!(url_count <= 2); // might detect both, but deduped by exact value
869    }
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}