Skip to main content

chant/
derivation.rs

1//! Derivation engine for extracting values from multiple sources.
2//!
3//! The derivation engine extracts values from:
4//! - **Branch name** - Current git branch (e.g., `sprint/2026-Q1-W4/PROJ-123`)
5//! - **File path** - Spec file path (e.g., `.chant/specs/teams/platform/...`)
6//! - **Environment variables** - Shell environment (e.g., `$TEAM_NAME`)
7//! - **Git user** - Git user.name or user.email from config
8//!
9//! For each source, the engine applies regex patterns to extract the first capture group.
10//! If a pattern doesn't match, the engine returns None for that field (graceful failure).
11
12use crate::config::{DerivationSource, DerivedFieldConfig, EnterpriseConfig, ValidationRule};
13use regex::Regex;
14use std::collections::HashMap;
15use std::path::PathBuf;
16
17/// Result of validating a derived value
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ValidationResult {
20    /// Value is valid
21    Valid,
22    /// Value is invalid but derivation proceeds with a warning
23    Warning(String),
24}
25
26/// Context containing all available data sources for derivation
27#[derive(Debug, Clone)]
28pub struct DerivationContext {
29    /// Current git branch name
30    pub branch_name: Option<String>,
31    /// Spec file path
32    pub spec_path: Option<PathBuf>,
33    /// Environment variables available for extraction
34    pub env_vars: HashMap<String, String>,
35    /// Git user.name from config
36    pub git_user_name: Option<String>,
37    /// Git user.email from config
38    pub git_user_email: Option<String>,
39}
40
41impl DerivationContext {
42    /// Create a new empty derivation context
43    pub fn new() -> Self {
44        Self {
45            branch_name: None,
46            spec_path: None,
47            env_vars: HashMap::new(),
48            git_user_name: None,
49            git_user_email: None,
50        }
51    }
52
53    /// Create a derivation context with environment variables
54    pub fn with_env_vars(env_vars: HashMap<String, String>) -> Self {
55        Self {
56            branch_name: None,
57            spec_path: None,
58            env_vars,
59            git_user_name: None,
60            git_user_email: None,
61        }
62    }
63}
64
65impl Default for DerivationContext {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71/// Build a DerivationContext populated with current environment data.
72///
73/// This creates a fully populated context with:
74/// - Current git branch name
75/// - Spec file path (constructed from spec_id and specs_dir)
76/// - All environment variables
77/// - Git user name and email from config
78///
79/// This is the canonical way to build a context for derivation operations.
80pub fn build_context(spec_id: &str, specs_dir: &std::path::Path) -> DerivationContext {
81    use crate::git;
82
83    let mut context = DerivationContext::new();
84
85    // Get current branch
86    if let Ok(branch) = git::get_current_branch() {
87        context.branch_name = Some(branch);
88    }
89
90    // Get spec path
91    let spec_path = specs_dir.join(format!("{}.md", spec_id));
92    context.spec_path = Some(spec_path);
93
94    // Capture environment variables
95    context.env_vars = std::env::vars().collect();
96
97    // Get git user info
98    let (name, email) = git::get_git_user_info();
99    context.git_user_name = name;
100    context.git_user_email = email;
101
102    context
103}
104
105/// Engine for deriving field values from configured sources
106#[derive(Debug, Clone)]
107pub struct DerivationEngine {
108    config: EnterpriseConfig,
109}
110
111impl DerivationEngine {
112    /// Create a new derivation engine with the given configuration
113    pub fn new(config: EnterpriseConfig) -> Self {
114        Self { config }
115    }
116
117    /// Derive all configured fields for a spec
118    ///
119    /// Returns a HashMap with field names as keys and derived values as values.
120    /// Fields that fail to match their pattern are omitted from the result.
121    /// If the enterprise config is empty, returns an empty HashMap (fast path).
122    pub fn derive_fields(&self, context: &DerivationContext) -> HashMap<String, String> {
123        // Fast path: if no derivation config, return empty
124        if self.config.derived.is_empty() {
125            return HashMap::new();
126        }
127
128        let mut result = HashMap::new();
129
130        for (field_name, field_config) in &self.config.derived {
131            if let Some(value) = self.derive_field(field_name, field_config, context) {
132                result.insert(field_name.clone(), value);
133            }
134        }
135
136        result
137    }
138
139    /// Derive a single field from its source using the configured pattern
140    ///
141    /// For Branch and Path sources: Extracts the first capture group from the pattern match.
142    /// For Env and GitUser sources: Returns the value directly (pattern is the field identifier).
143    /// Returns None if the pattern doesn't match or the source is unavailable.
144    /// Validates the derived value if a validation rule is configured.
145    fn derive_field(
146        &self,
147        field_name: &str,
148        config: &DerivedFieldConfig,
149        context: &DerivationContext,
150    ) -> Option<String> {
151        let value = match config.from {
152            DerivationSource::Branch => {
153                let source_value = self.extract_from_branch(context)?;
154                self.apply_pattern(&config.pattern, &source_value)
155                    .or_else(|| {
156                        eprintln!(
157                            "Warning: derivation pattern for field '{}' did not match source",
158                            field_name
159                        );
160                        None
161                    })?
162            }
163            DerivationSource::Path => {
164                let source_value = self.extract_from_path(context)?;
165                self.apply_pattern(&config.pattern, &source_value)
166                    .or_else(|| {
167                        eprintln!(
168                            "Warning: derivation pattern for field '{}' did not match source",
169                            field_name
170                        );
171                        None
172                    })?
173            }
174            DerivationSource::Env => {
175                // For Env, pattern is the environment variable name
176                self.extract_from_env(context, &config.pattern)?
177            }
178            DerivationSource::GitUser => {
179                // For GitUser, pattern is "name" or "email"
180                self.extract_from_git_user(context, &config.pattern)?
181            }
182        };
183
184        // Validate the derived value if a validation rule is configured
185        if let Some(validation) = &config.validate {
186            match self.validate_derived_value(field_name, &value, validation) {
187                ValidationResult::Valid => {
188                    // Value is valid, proceed
189                }
190                ValidationResult::Warning(msg) => {
191                    // Log warning but still include the value in results
192                    eprintln!("Warning: {}", msg);
193                }
194            }
195        }
196
197        Some(value)
198    }
199
200    /// Extract value from branch name source
201    fn extract_from_branch(&self, context: &DerivationContext) -> Option<String> {
202        context.branch_name.clone()
203    }
204
205    /// Extract value from file path source
206    fn extract_from_path(&self, context: &DerivationContext) -> Option<String> {
207        context
208            .spec_path
209            .as_ref()
210            .and_then(|path| path.to_str().map(|s| s.to_string()))
211    }
212
213    /// Extract value from environment variable source
214    ///
215    /// The pattern parameter is treated as the environment variable name
216    fn extract_from_env(&self, context: &DerivationContext, env_name: &str) -> Option<String> {
217        context.env_vars.get(env_name).cloned()
218    }
219
220    /// Extract value from git user source
221    ///
222    /// The pattern parameter can be "name" for user.name or "email" for user.email
223    fn extract_from_git_user(
224        &self,
225        context: &DerivationContext,
226        field_type: &str,
227    ) -> Option<String> {
228        match field_type {
229            "name" => context.git_user_name.clone(),
230            "email" => context.git_user_email.clone(),
231            _ => None,
232        }
233    }
234
235    /// Apply regex pattern to extract the first capture group
236    ///
237    /// Returns None if pattern is invalid or doesn't match
238    fn apply_pattern(&self, pattern: &str, source: &str) -> Option<String> {
239        let regex = Regex::new(pattern).ok()?;
240        regex
241            .captures(source)?
242            .get(1)
243            .map(|m| m.as_str().to_string())
244    }
245
246    /// Validate a derived value against its validation rule
247    ///
248    /// Returns Valid if the value passes validation, or Warning if it fails.
249    /// Does not prevent the value from being included in results.
250    fn validate_derived_value(
251        &self,
252        field_name: &str,
253        value: &str,
254        validation: &ValidationRule,
255    ) -> ValidationResult {
256        match validation {
257            ValidationRule::Enum { values } => {
258                if values.contains(&value.to_string()) {
259                    ValidationResult::Valid
260                } else {
261                    ValidationResult::Warning(format!(
262                        "Field '{}' value '{}' is not in allowed enum values: {}",
263                        field_name,
264                        value,
265                        values.join(", ")
266                    ))
267                }
268            }
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use std::path::PathBuf;
277
278    fn create_test_engine(derived: HashMap<String, DerivedFieldConfig>) -> DerivationEngine {
279        DerivationEngine::new(EnterpriseConfig {
280            derived,
281            required: vec![],
282        })
283    }
284
285    // =========================================================================
286    // TEST HELPERS FOR SOURCE TYPE TESTS
287    // =========================================================================
288
289    /// Test helper for derivation source tests
290    fn assert_derive_from_source(
291        field_name: &str,
292        config: DerivedFieldConfig,
293        context: DerivationContext,
294        expected: Option<&str>,
295    ) {
296        let mut derived = HashMap::new();
297        derived.insert(field_name.to_string(), config);
298        let engine = create_test_engine(derived);
299        let result = engine.derive_fields(&context);
300
301        match expected {
302            Some(val) => assert_eq!(result.get(field_name), Some(&val.to_string())),
303            None => assert!(!result.contains_key(field_name)),
304        }
305    }
306
307    // =========================================================================
308    // BRANCH NAME EXTRACTION TESTS
309    // =========================================================================
310
311    #[test]
312    fn test_derive_from_branch_basic() {
313        let config = DerivedFieldConfig {
314            from: DerivationSource::Branch,
315            pattern: r"^(dev|staging|prod)".to_string(),
316            validate: None,
317        };
318        let mut context = DerivationContext::new();
319        context.branch_name = Some("prod/feature-123".to_string());
320
321        assert_derive_from_source("env", config, context, Some("prod"));
322    }
323
324    #[test]
325    fn test_derive_from_branch_with_capture_group() {
326        let config = DerivedFieldConfig {
327            from: DerivationSource::Branch,
328            pattern: r"sprint/.*/(PROJ-\d+)".to_string(),
329            validate: None,
330        };
331        let mut context = DerivationContext::new();
332        context.branch_name = Some("sprint/2026-Q1-W4/PROJ-123".to_string());
333
334        assert_derive_from_source("project", config, context, Some("PROJ-123"));
335    }
336
337    #[test]
338    fn test_derive_from_branch_no_match() {
339        let config = DerivedFieldConfig {
340            from: DerivationSource::Branch,
341            pattern: r"^(dev|staging|prod)".to_string(),
342            validate: None,
343        };
344        let mut context = DerivationContext::new();
345        context.branch_name = Some("feature/my-branch".to_string());
346
347        assert_derive_from_source("env", config, context, None);
348    }
349
350    #[test]
351    fn test_derive_from_branch_missing() {
352        let config = DerivedFieldConfig {
353            from: DerivationSource::Branch,
354            pattern: r"^(dev|staging|prod)".to_string(),
355            validate: None,
356        };
357        let context = DerivationContext::new(); // No branch_name
358
359        assert_derive_from_source("env", config, context, None);
360    }
361
362    // =========================================================================
363    // FILE PATH EXTRACTION TESTS
364    // =========================================================================
365
366    #[test]
367    fn test_derive_from_path_basic() {
368        let config = DerivedFieldConfig {
369            from: DerivationSource::Path,
370            pattern: r"specs/([a-z]+)/".to_string(),
371            validate: None,
372        };
373        let mut context = DerivationContext::new();
374        context.spec_path = Some(PathBuf::from(".chant/specs/platform/feature.md"));
375
376        assert_derive_from_source("team", config, context, Some("platform"));
377    }
378
379    #[test]
380    fn test_derive_from_path_with_multiple_captures() {
381        let config = DerivedFieldConfig {
382            from: DerivationSource::Path,
383            pattern: r"specs/([a-z]+)/([A-Z0-9]+)-".to_string(),
384            validate: None,
385        };
386        let mut context = DerivationContext::new();
387        context.spec_path = Some(PathBuf::from(".chant/specs/teams/PROJ-123-feature.md"));
388
389        // Should extract first capture group only
390        assert_derive_from_source("project", config, context, Some("teams"));
391    }
392
393    #[test]
394    fn test_derive_from_path_no_match() {
395        let config = DerivedFieldConfig {
396            from: DerivationSource::Path,
397            pattern: r"specs/([a-z]+)/".to_string(),
398            validate: None,
399        };
400        let mut context = DerivationContext::new();
401        context.spec_path = Some(PathBuf::from(".chant/specs/feature.md"));
402
403        assert_derive_from_source("team", config, context, None);
404    }
405
406    #[test]
407    fn test_derive_from_path_missing() {
408        let config = DerivedFieldConfig {
409            from: DerivationSource::Path,
410            pattern: r"specs/([a-z]+)/".to_string(),
411            validate: None,
412        };
413        let context = DerivationContext::new(); // No spec_path
414
415        assert_derive_from_source("team", config, context, None);
416    }
417
418    // =========================================================================
419    // ENVIRONMENT VARIABLE EXTRACTION TESTS
420    // =========================================================================
421
422    #[test]
423    fn test_derive_from_env_basic() {
424        let config = DerivedFieldConfig {
425            from: DerivationSource::Env,
426            pattern: "TEAM_NAME".to_string(),
427            validate: None,
428        };
429        let mut env_vars = HashMap::new();
430        env_vars.insert("TEAM_NAME".to_string(), "platform".to_string());
431        let context = DerivationContext::with_env_vars(env_vars);
432
433        assert_derive_from_source("team", config, context, Some("platform"));
434    }
435
436    #[test]
437    fn test_derive_from_env_with_pattern_match() {
438        let config = DerivedFieldConfig {
439            from: DerivationSource::Env,
440            pattern: "ENVIRONMENT".to_string(),
441            validate: None,
442        };
443        let mut env_vars = HashMap::new();
444        env_vars.insert("ENVIRONMENT".to_string(), "production".to_string());
445        let context = DerivationContext::with_env_vars(env_vars);
446
447        assert_derive_from_source("env_name", config, context, Some("production"));
448    }
449
450    #[test]
451    fn test_derive_from_env_missing_variable() {
452        let config = DerivedFieldConfig {
453            from: DerivationSource::Env,
454            pattern: "TEAM_NAME".to_string(),
455            validate: None,
456        };
457        let context = DerivationContext::new(); // No env vars
458
459        assert_derive_from_source("team", config, context, None);
460    }
461
462    #[test]
463    fn test_derive_from_env_undefined_variable() {
464        let config = DerivedFieldConfig {
465            from: DerivationSource::Env,
466            pattern: "TEAM_NAME".to_string(),
467            validate: None,
468        };
469        let mut env_vars = HashMap::new();
470        env_vars.insert("OTHER_VAR".to_string(), "value".to_string());
471        let context = DerivationContext::with_env_vars(env_vars);
472
473        assert_derive_from_source("team", config, context, None);
474    }
475
476    // =========================================================================
477    // GIT USER EXTRACTION TESTS
478    // =========================================================================
479
480    #[test]
481    fn test_derive_from_git_user_name() {
482        let config = DerivedFieldConfig {
483            from: DerivationSource::GitUser,
484            pattern: "name".to_string(),
485            validate: None,
486        };
487        let mut context = DerivationContext::new();
488        context.git_user_name = Some("John Doe".to_string());
489
490        assert_derive_from_source("author", config, context, Some("John Doe"));
491    }
492
493    #[test]
494    fn test_derive_from_git_user_email() {
495        let config = DerivedFieldConfig {
496            from: DerivationSource::GitUser,
497            pattern: "email".to_string(),
498            validate: None,
499        };
500        let mut context = DerivationContext::new();
501        context.git_user_email = Some("john@example.com".to_string());
502
503        assert_derive_from_source("author_email", config, context, Some("john@example.com"));
504    }
505
506    #[test]
507    fn test_derive_from_git_user_invalid_field() {
508        let config = DerivedFieldConfig {
509            from: DerivationSource::GitUser,
510            pattern: "invalid".to_string(),
511            validate: None,
512        };
513        let mut context = DerivationContext::new();
514        context.git_user_name = Some("John Doe".to_string());
515
516        assert_derive_from_source("author", config, context, None);
517    }
518
519    #[test]
520    fn test_derive_from_git_user_missing_name() {
521        let config = DerivedFieldConfig {
522            from: DerivationSource::GitUser,
523            pattern: "name".to_string(),
524            validate: None,
525        };
526        let context = DerivationContext::new(); // No git_user_name
527
528        assert_derive_from_source("author", config, context, None);
529    }
530
531    // =========================================================================
532    // GRACEFUL FAILURE TESTS
533    // =========================================================================
534
535    #[test]
536    fn test_invalid_regex_pattern() {
537        let mut derived = HashMap::new();
538        derived.insert(
539            "test".to_string(),
540            DerivedFieldConfig {
541                from: DerivationSource::Branch,
542                pattern: "[invalid regex".to_string(), // Invalid regex
543                validate: None,
544            },
545        );
546
547        let engine = create_test_engine(derived);
548        let mut context = DerivationContext::new();
549        context.branch_name = Some("test".to_string());
550
551        let result = engine.derive_fields(&context);
552        // Invalid regex should result in None (graceful failure)
553        assert!(!result.contains_key("test"));
554    }
555
556    // =========================================================================
557    // EMPTY CONFIG TEST
558    // =========================================================================
559
560    #[test]
561    fn test_empty_config_returns_empty_map() {
562        let engine = create_test_engine(HashMap::new());
563        let mut context = DerivationContext::new();
564        context.branch_name = Some("main".to_string());
565        context.spec_path = Some(PathBuf::from(".chant/specs/test.md"));
566        let mut env_vars = HashMap::new();
567        env_vars.insert("TEST_VAR".to_string(), "value".to_string());
568        context.env_vars = env_vars;
569        context.git_user_name = Some("Test User".to_string());
570
571        let result = engine.derive_fields(&context);
572        assert!(result.is_empty());
573    }
574
575    // =========================================================================
576    // MULTIPLE FIELDS TEST
577    // =========================================================================
578
579    #[test]
580    fn test_derive_multiple_fields() {
581        let mut derived = HashMap::new();
582        derived.insert(
583            "env".to_string(),
584            DerivedFieldConfig {
585                from: DerivationSource::Branch,
586                pattern: r"^(dev|staging|prod)".to_string(),
587                validate: None,
588            },
589        );
590        derived.insert(
591            "team".to_string(),
592            DerivedFieldConfig {
593                from: DerivationSource::Env,
594                pattern: "TEAM_NAME".to_string(),
595                validate: None,
596            },
597        );
598        derived.insert(
599            "author".to_string(),
600            DerivedFieldConfig {
601                from: DerivationSource::GitUser,
602                pattern: "name".to_string(),
603                validate: None,
604            },
605        );
606
607        let engine = create_test_engine(derived);
608        let mut context = DerivationContext::new();
609        context.branch_name = Some("prod/feature".to_string());
610        let mut env_vars = HashMap::new();
611        env_vars.insert("TEAM_NAME".to_string(), "platform".to_string());
612        context.env_vars = env_vars;
613        context.git_user_name = Some("Jane Doe".to_string());
614
615        let result = engine.derive_fields(&context);
616        assert_eq!(result.len(), 3);
617        assert_eq!(result.get("env"), Some(&"prod".to_string()));
618        assert_eq!(result.get("team"), Some(&"platform".to_string()));
619        assert_eq!(result.get("author"), Some(&"Jane Doe".to_string()));
620    }
621
622    #[test]
623    fn test_derive_multiple_fields_partial_success() {
624        let mut derived = HashMap::new();
625        derived.insert(
626            "env".to_string(),
627            DerivedFieldConfig {
628                from: DerivationSource::Branch,
629                pattern: r"^(dev|staging|prod)".to_string(),
630                validate: None,
631            },
632        );
633        derived.insert(
634            "team".to_string(),
635            DerivedFieldConfig {
636                from: DerivationSource::Env,
637                pattern: "MISSING_VAR".to_string(),
638                validate: None,
639            },
640        );
641        derived.insert(
642            "author".to_string(),
643            DerivedFieldConfig {
644                from: DerivationSource::GitUser,
645                pattern: "name".to_string(),
646                validate: None,
647            },
648        );
649
650        let engine = create_test_engine(derived);
651        let mut context = DerivationContext::new();
652        context.branch_name = Some("prod/feature".to_string());
653        context.git_user_name = Some("Jane Doe".to_string());
654
655        let result = engine.derive_fields(&context);
656        // Only env and author should be derived
657        assert_eq!(result.len(), 2);
658        assert_eq!(result.get("env"), Some(&"prod".to_string()));
659        assert!(!result.contains_key("team"));
660        assert_eq!(result.get("author"), Some(&"Jane Doe".to_string()));
661    }
662
663    // =========================================================================
664    // VALIDATION TESTS
665    // =========================================================================
666
667    #[test]
668    fn test_enum_validation_valid_value() {
669        let mut derived = HashMap::new();
670        derived.insert(
671            "team".to_string(),
672            DerivedFieldConfig {
673                from: DerivationSource::Env,
674                pattern: "TEAM_NAME".to_string(),
675                validate: Some(ValidationRule::Enum {
676                    values: vec![
677                        "platform".to_string(),
678                        "frontend".to_string(),
679                        "backend".to_string(),
680                    ],
681                }),
682            },
683        );
684
685        let engine = create_test_engine(derived);
686        let mut env_vars = HashMap::new();
687        env_vars.insert("TEAM_NAME".to_string(), "platform".to_string());
688        let context = DerivationContext::with_env_vars(env_vars);
689
690        let result = engine.derive_fields(&context);
691        // Field should be included even with validation
692        assert_eq!(result.get("team"), Some(&"platform".to_string()));
693    }
694
695    #[test]
696    fn test_enum_validation_invalid_value() {
697        let mut derived = HashMap::new();
698        derived.insert(
699            "team".to_string(),
700            DerivedFieldConfig {
701                from: DerivationSource::Env,
702                pattern: "TEAM_NAME".to_string(),
703                validate: Some(ValidationRule::Enum {
704                    values: vec![
705                        "platform".to_string(),
706                        "frontend".to_string(),
707                        "backend".to_string(),
708                    ],
709                }),
710            },
711        );
712
713        let engine = create_test_engine(derived);
714        let mut env_vars = HashMap::new();
715        env_vars.insert("TEAM_NAME".to_string(), "invalid-team".to_string());
716        let context = DerivationContext::with_env_vars(env_vars);
717
718        let result = engine.derive_fields(&context);
719        // Field should still be included even if validation fails
720        assert_eq!(result.get("team"), Some(&"invalid-team".to_string()));
721    }
722
723    #[test]
724    fn test_enum_validation_with_branch_source() {
725        let mut derived = HashMap::new();
726        derived.insert(
727            "environment".to_string(),
728            DerivedFieldConfig {
729                from: DerivationSource::Branch,
730                pattern: r"^(dev|staging|prod)".to_string(),
731                validate: Some(ValidationRule::Enum {
732                    values: vec!["dev".to_string(), "staging".to_string(), "prod".to_string()],
733                }),
734            },
735        );
736
737        let engine = create_test_engine(derived);
738        let mut context = DerivationContext::new();
739        context.branch_name = Some("staging/new-feature".to_string());
740
741        let result = engine.derive_fields(&context);
742        assert_eq!(result.get("environment"), Some(&"staging".to_string()));
743    }
744
745    #[test]
746    fn test_enum_validation_with_branch_source_invalid() {
747        let mut derived = HashMap::new();
748        derived.insert(
749            "environment".to_string(),
750            DerivedFieldConfig {
751                from: DerivationSource::Branch,
752                pattern: r"^([a-z]+)".to_string(),
753                validate: Some(ValidationRule::Enum {
754                    values: vec!["dev".to_string(), "staging".to_string(), "prod".to_string()],
755                }),
756            },
757        );
758
759        let engine = create_test_engine(derived);
760        let mut context = DerivationContext::new();
761        context.branch_name = Some("testing/new-feature".to_string());
762
763        let result = engine.derive_fields(&context);
764        // Value should still be included even though "testing" is not in enum
765        assert_eq!(result.get("environment"), Some(&"testing".to_string()));
766    }
767
768    #[test]
769    fn test_validation_skipped_when_no_rule_configured() {
770        let mut derived = HashMap::new();
771        derived.insert(
772            "team".to_string(),
773            DerivedFieldConfig {
774                from: DerivationSource::Env,
775                pattern: "TEAM_NAME".to_string(),
776                validate: None, // No validation rule
777            },
778        );
779
780        let engine = create_test_engine(derived);
781        let mut env_vars = HashMap::new();
782        env_vars.insert("TEAM_NAME".to_string(), "any-value".to_string());
783        let context = DerivationContext::with_env_vars(env_vars);
784
785        let result = engine.derive_fields(&context);
786        // Field should be included without validation
787        assert_eq!(result.get("team"), Some(&"any-value".to_string()));
788    }
789
790    #[test]
791    fn test_enum_validation_with_path_source() {
792        let mut derived = HashMap::new();
793        derived.insert(
794            "team".to_string(),
795            DerivedFieldConfig {
796                from: DerivationSource::Path,
797                pattern: r"specs/([a-z]+)/".to_string(),
798                validate: Some(ValidationRule::Enum {
799                    values: vec![
800                        "platform".to_string(),
801                        "frontend".to_string(),
802                        "backend".to_string(),
803                    ],
804                }),
805            },
806        );
807
808        let engine = create_test_engine(derived);
809        let mut context = DerivationContext::new();
810        context.spec_path = Some(PathBuf::from(".chant/specs/backend/feature.md"));
811
812        let result = engine.derive_fields(&context);
813        assert_eq!(result.get("team"), Some(&"backend".to_string()));
814    }
815
816    #[test]
817    fn test_enum_validation_case_sensitive() {
818        let mut derived = HashMap::new();
819        derived.insert(
820            "team".to_string(),
821            DerivedFieldConfig {
822                from: DerivationSource::Env,
823                pattern: "TEAM_NAME".to_string(),
824                validate: Some(ValidationRule::Enum {
825                    values: vec!["Platform".to_string(), "Frontend".to_string()],
826                }),
827            },
828        );
829
830        let engine = create_test_engine(derived);
831        let mut env_vars = HashMap::new();
832        env_vars.insert("TEAM_NAME".to_string(), "platform".to_string());
833        let context = DerivationContext::with_env_vars(env_vars);
834
835        let result = engine.derive_fields(&context);
836        // "platform" does not match "Platform" (case sensitive)
837        // Field should still be included
838        assert_eq!(result.get("team"), Some(&"platform".to_string()));
839    }
840
841    #[test]
842    fn test_multiple_fields_with_mixed_validation() {
843        let mut derived = HashMap::new();
844        derived.insert(
845            "team".to_string(),
846            DerivedFieldConfig {
847                from: DerivationSource::Env,
848                pattern: "TEAM_NAME".to_string(),
849                validate: Some(ValidationRule::Enum {
850                    values: vec!["platform".to_string(), "frontend".to_string()],
851                }),
852            },
853        );
854        derived.insert(
855            "environment".to_string(),
856            DerivedFieldConfig {
857                from: DerivationSource::Branch,
858                pattern: r"^(dev|staging|prod)".to_string(),
859                validate: None, // No validation
860            },
861        );
862        derived.insert(
863            "author".to_string(),
864            DerivedFieldConfig {
865                from: DerivationSource::GitUser,
866                pattern: "name".to_string(),
867                validate: Some(ValidationRule::Enum {
868                    values: vec!["Alice".to_string(), "Bob".to_string()],
869                }),
870            },
871        );
872
873        let engine = create_test_engine(derived);
874        let mut context = DerivationContext::new();
875        let mut env_vars = HashMap::new();
876        env_vars.insert("TEAM_NAME".to_string(), "backend".to_string()); // Invalid
877        context.env_vars = env_vars;
878        context.branch_name = Some("prod/feature".to_string());
879        context.git_user_name = Some("Charlie".to_string()); // Invalid
880
881        let result = engine.derive_fields(&context);
882        // All three should be included despite validation warnings
883        assert_eq!(result.len(), 3);
884        assert_eq!(result.get("team"), Some(&"backend".to_string()));
885        assert_eq!(result.get("environment"), Some(&"prod".to_string()));
886        assert_eq!(result.get("author"), Some(&"Charlie".to_string()));
887    }
888
889    // =========================================================================
890    // UNICODE HANDLING TESTS
891    // =========================================================================
892
893    #[test]
894    fn test_branch_with_unicode_characters() {
895        let mut derived = HashMap::new();
896        derived.insert(
897            "project".to_string(),
898            DerivedFieldConfig {
899                from: DerivationSource::Branch,
900                pattern: "feature/([^/]+)/".to_string(),
901                validate: None,
902            },
903        );
904        derived.insert(
905            "description".to_string(),
906            DerivedFieldConfig {
907                from: DerivationSource::Branch,
908                pattern: "feature/[^/]+/(.+)".to_string(),
909                validate: None,
910            },
911        );
912
913        let engine = create_test_engine(derived);
914        let mut context = DerivationContext::new();
915        context.branch_name = Some("feature/项目-123/amélioration".to_string());
916
917        let result = engine.derive_fields(&context);
918        assert_eq!(result.get("project"), Some(&"项目-123".to_string()));
919        assert_eq!(result.get("description"), Some(&"amélioration".to_string()));
920    }
921
922    #[test]
923    fn test_env_value_with_unicode() {
924        let mut derived = HashMap::new();
925        derived.insert(
926            "author".to_string(),
927            DerivedFieldConfig {
928                from: DerivationSource::Env,
929                pattern: "AUTHOR_NAME".to_string(),
930                validate: None,
931            },
932        );
933        derived.insert(
934            "team".to_string(),
935            DerivedFieldConfig {
936                from: DerivationSource::Env,
937                pattern: "TEAM_NAME".to_string(),
938                validate: None,
939            },
940        );
941        derived.insert(
942            "desc".to_string(),
943            DerivedFieldConfig {
944                from: DerivationSource::Env,
945                pattern: "DESCRIPTION".to_string(),
946                validate: None,
947            },
948        );
949
950        let engine = create_test_engine(derived);
951        let mut env_vars = HashMap::new();
952        env_vars.insert("AUTHOR_NAME".to_string(), "José García".to_string());
953        env_vars.insert("TEAM_NAME".to_string(), "Платформа".to_string());
954        env_vars.insert("DESCRIPTION".to_string(), "Fix 🐛 in parser".to_string());
955        let context = DerivationContext::with_env_vars(env_vars);
956
957        let result = engine.derive_fields(&context);
958        assert_eq!(result.get("author"), Some(&"José García".to_string()));
959        assert_eq!(result.get("team"), Some(&"Платформа".to_string()));
960        assert_eq!(result.get("desc"), Some(&"Fix 🐛 in parser".to_string()));
961    }
962
963    #[test]
964    fn test_git_user_with_unicode() {
965        let mut derived = HashMap::new();
966        derived.insert(
967            "author".to_string(),
968            DerivedFieldConfig {
969                from: DerivationSource::GitUser,
970                pattern: "name".to_string(),
971                validate: None,
972            },
973        );
974        derived.insert(
975            "email".to_string(),
976            DerivedFieldConfig {
977                from: DerivationSource::GitUser,
978                pattern: "email".to_string(),
979                validate: None,
980            },
981        );
982
983        let engine = create_test_engine(derived);
984        let mut context = DerivationContext::new();
985        context.git_user_name = Some("François Müller".to_string());
986        context.git_user_email = Some("françois.müller@example.com".to_string());
987
988        let result = engine.derive_fields(&context);
989        assert_eq!(result.get("author"), Some(&"François Müller".to_string()));
990        assert_eq!(
991            result.get("email"),
992            Some(&"françois.müller@example.com".to_string())
993        );
994    }
995
996    #[test]
997    fn test_path_with_unicode_directory_names() {
998        let mut derived = HashMap::new();
999        derived.insert(
1000            "team".to_string(),
1001            DerivedFieldConfig {
1002                from: DerivationSource::Path,
1003                pattern: "specs/([^/]+)/".to_string(),
1004                validate: None,
1005            },
1006        );
1007
1008        let engine = create_test_engine(derived);
1009        let mut context = DerivationContext::new();
1010        context.spec_path = Some(PathBuf::from(".chant/specs/平台/文档.md"));
1011
1012        let result = engine.derive_fields(&context);
1013        assert_eq!(result.get("team"), Some(&"平台".to_string()));
1014    }
1015
1016    // =========================================================================
1017    // SPECIAL CHARACTERS IN VALUES TESTS
1018    // =========================================================================
1019
1020    #[test]
1021    fn test_special_characters_branch_with_slashes_hyphens_dots() {
1022        let config1 = DerivedFieldConfig {
1023            from: DerivationSource::Branch,
1024            pattern: "([A-Z]+-\\d+)".to_string(),
1025            validate: None,
1026        };
1027        let config2 = DerivedFieldConfig {
1028            from: DerivationSource::Branch,
1029            pattern: "feature/(.+)".to_string(),
1030            validate: None,
1031        };
1032        let mut context = DerivationContext::new();
1033        context.branch_name = Some("feature/ABC-123/user-name.test".to_string());
1034
1035        assert_derive_from_source("ticket", config1, context.clone(), Some("ABC-123"));
1036        assert_derive_from_source(
1037            "full_path",
1038            config2,
1039            context,
1040            Some("ABC-123/user-name.test"),
1041        );
1042    }
1043
1044    #[test]
1045    fn test_special_characters_env_value_with_spaces_and_quotes() {
1046        let mut env_vars = HashMap::new();
1047        env_vars.insert("TEAM_NAME".to_string(), "Platform Team".to_string());
1048        env_vars.insert(
1049            "DESCRIPTION".to_string(),
1050            "This is a \"test\" value".to_string(),
1051        );
1052        env_vars.insert(
1053            "NOTES".to_string(),
1054            "Value with 'single' and \"double\" quotes".to_string(),
1055        );
1056
1057        let config1 = DerivedFieldConfig {
1058            from: DerivationSource::Env,
1059            pattern: "TEAM_NAME".to_string(),
1060            validate: None,
1061        };
1062        let config2 = DerivedFieldConfig {
1063            from: DerivationSource::Env,
1064            pattern: "DESCRIPTION".to_string(),
1065            validate: None,
1066        };
1067        let config3 = DerivedFieldConfig {
1068            from: DerivationSource::Env,
1069            pattern: "NOTES".to_string(),
1070            validate: None,
1071        };
1072        let context = DerivationContext::with_env_vars(env_vars);
1073
1074        assert_derive_from_source("team", config1, context.clone(), Some("Platform Team"));
1075        assert_derive_from_source(
1076            "desc",
1077            config2,
1078            context.clone(),
1079            Some("This is a \"test\" value"),
1080        );
1081        assert_derive_from_source(
1082            "notes",
1083            config3,
1084            context,
1085            Some("Value with 'single' and \"double\" quotes"),
1086        );
1087    }
1088
1089    #[test]
1090    fn test_special_characters_path_with_dots_and_hyphens() {
1091        let config1 = DerivedFieldConfig {
1092            from: DerivationSource::Path,
1093            pattern: "specs/([^/]+)/".to_string(),
1094            validate: None,
1095        };
1096        let config2 = DerivedFieldConfig {
1097            from: DerivationSource::Path,
1098            pattern: "/([^/]+\\.md)$".to_string(),
1099            validate: None,
1100        };
1101        let mut context = DerivationContext::new();
1102        context.spec_path = Some(PathBuf::from(".chant/specs/platform-team/feature.v2.md"));
1103
1104        assert_derive_from_source("component", config1, context.clone(), Some("platform-team"));
1105        assert_derive_from_source("filename", config2, context, Some("feature.v2.md"));
1106    }
1107
1108    #[test]
1109    fn test_special_characters_value_with_regex_metacharacters() {
1110        let config = DerivedFieldConfig {
1111            from: DerivationSource::Branch,
1112            pattern: "feature/(.+)".to_string(),
1113            validate: None,
1114        };
1115        let mut context = DerivationContext::new();
1116        context.branch_name = Some("feature/fix-[bug]-in-(parser)".to_string());
1117
1118        assert_derive_from_source(
1119            "description",
1120            config,
1121            context,
1122            Some("fix-[bug]-in-(parser)"),
1123        );
1124    }
1125
1126    #[test]
1127    fn test_special_characters_env_value_with_commas_and_special_chars() {
1128        let mut env_vars = HashMap::new();
1129        env_vars.insert("TAGS".to_string(), "bug,feature,urgent".to_string());
1130        env_vars.insert("EXPRESSION".to_string(), "value = 1 + 2 * 3".to_string());
1131        env_vars.insert(
1132            "PATH_LIKE".to_string(),
1133            "/usr/bin:/usr/local/bin".to_string(),
1134        );
1135
1136        let config1 = DerivedFieldConfig {
1137            from: DerivationSource::Env,
1138            pattern: "TAGS".to_string(),
1139            validate: None,
1140        };
1141        let config2 = DerivedFieldConfig {
1142            from: DerivationSource::Env,
1143            pattern: "EXPRESSION".to_string(),
1144            validate: None,
1145        };
1146        let config3 = DerivedFieldConfig {
1147            from: DerivationSource::Env,
1148            pattern: "PATH_LIKE".to_string(),
1149            validate: None,
1150        };
1151        let context = DerivationContext::with_env_vars(env_vars);
1152
1153        assert_derive_from_source("tags", config1, context.clone(), Some("bug,feature,urgent"));
1154        assert_derive_from_source("expr", config2, context.clone(), Some("value = 1 + 2 * 3"));
1155        assert_derive_from_source("path", config3, context, Some("/usr/bin:/usr/local/bin"));
1156    }
1157
1158    #[test]
1159    fn test_special_characters_branch_with_multiple_regex_metacharacters() {
1160        let config = DerivedFieldConfig {
1161            from: DerivationSource::Branch,
1162            pattern: "fix/(.+)".to_string(),
1163            validate: None,
1164        };
1165        let mut context = DerivationContext::new();
1166        context.branch_name = Some("fix/handle-$var.and^chars+more*stuff".to_string());
1167
1168        assert_derive_from_source(
1169            "desc",
1170            config,
1171            context,
1172            Some("handle-$var.and^chars+more*stuff"),
1173        );
1174    }
1175}