acp/constraints/
types.rs

1//! @acp:module "Constraint Types"
2//! @acp:summary "Core constraint types for AI behavioral guardrails"
3//! @acp:domain cli
4//! @acp:layer model
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// @acp:summary "Complete constraint set for a scope (RFC-001 compliant)"
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct Constraints {
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub style: Option<StyleConstraint>,
15
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub mutation: Option<MutationConstraint>,
18
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub behavior: Option<BehaviorModifier>,
21
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub quality: Option<QualityGate>,
24
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub deprecation: Option<DeprecationInfo>,
27
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub references: Vec<Reference>,
30
31    /// RFC-001: Aggregated self-documenting directive from annotations
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub directive: Option<String>,
34
35    /// RFC-001: Whether directive was auto-generated
36    #[serde(default, skip_serializing_if = "is_false")]
37    pub auto_generated: bool,
38}
39
40fn is_false(b: &bool) -> bool {
41    !*b
42}
43
44impl Constraints {
45    /// Merge with another constraint set (other takes precedence)
46    /// RFC-001: Aggregates directives when merging
47    pub fn merge(&self, other: &Constraints) -> Constraints {
48        Constraints {
49            style: other.style.clone().or_else(|| self.style.clone()),
50            mutation: other.mutation.clone().or_else(|| self.mutation.clone()),
51            behavior: other.behavior.clone().or_else(|| self.behavior.clone()),
52            quality: other.quality.clone().or_else(|| self.quality.clone()),
53            deprecation: other
54                .deprecation
55                .clone()
56                .or_else(|| self.deprecation.clone()),
57            references: {
58                let mut refs = self.references.clone();
59                refs.extend(other.references.clone());
60                refs
61            },
62            // RFC-001: Aggregate directives - more specific (other) takes precedence
63            directive: other.directive.clone().or_else(|| self.directive.clone()),
64            auto_generated: other.directive.is_some() && other.auto_generated
65                || other.directive.is_none() && self.auto_generated,
66        }
67    }
68
69    /// Check if AI is allowed to modify based on these constraints
70    pub fn can_modify(&self, operation: &str) -> ModifyPermission {
71        if let Some(mutation) = &self.mutation {
72            match mutation.level {
73                LockLevel::Frozen => {
74                    return ModifyPermission::Denied {
75                        reason: "Code is frozen and cannot be modified".to_string(),
76                    };
77                }
78                LockLevel::Restricted => {
79                    if let Some(allowed) = &mutation.allowed_operations {
80                        if !allowed.iter().any(|op| op == operation) {
81                            return ModifyPermission::Denied {
82                                reason: format!(
83                                    "Operation '{}' not allowed. Allowed: {:?}",
84                                    operation, allowed
85                                ),
86                            };
87                        }
88                    }
89                    return ModifyPermission::RequiresApproval {
90                        reason: "Code is restricted".to_string(),
91                    };
92                }
93                LockLevel::ApprovalRequired => {
94                    return ModifyPermission::RequiresApproval {
95                        reason: mutation.reason.clone().unwrap_or_default(),
96                    };
97                }
98                _ => {}
99            }
100        }
101
102        ModifyPermission::Allowed
103    }
104
105    /// Get requirements that must be met for changes
106    pub fn get_requirements(&self) -> Vec<String> {
107        let mut reqs = Vec::new();
108
109        if let Some(mutation) = &self.mutation {
110            if mutation.requires_tests {
111                reqs.push("tests".to_string());
112            }
113            if mutation.requires_docs {
114                reqs.push("documentation".to_string());
115            }
116            if mutation.requires_approval {
117                reqs.push("approval".to_string());
118            }
119        }
120
121        if let Some(quality) = &self.quality {
122            if quality.tests_required {
123                reqs.push("tests".to_string());
124            }
125            if quality.security_review {
126                reqs.push("security-review".to_string());
127            }
128        }
129
130        reqs.sort();
131        reqs.dedup();
132        reqs
133    }
134}
135
136/// @acp:summary "Result of checking modification permission"
137#[derive(Debug, Clone)]
138pub enum ModifyPermission {
139    Allowed,
140    RequiresApproval { reason: String },
141    Denied { reason: String },
142}
143
144/// @acp:summary "Style/formatting constraints"
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct StyleConstraint {
147    /// Style guide identifier (e.g., "tailwindcss-v4", "google-python")
148    pub guide: String,
149
150    /// URL to authoritative documentation
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub reference: Option<String>,
153
154    /// Specific rules to follow
155    #[serde(default)]
156    pub rules: Vec<String>,
157
158    /// Linter config file to use
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub linter: Option<String>,
161}
162
163/// @acp:summary "Mutation/modification constraints"
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct MutationConstraint {
166    /// Lock level
167    #[serde(default)]
168    pub level: LockLevel,
169
170    /// Reason for restriction
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub reason: Option<String>,
173
174    /// Contact for questions
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub contact: Option<String>,
177
178    /// Requires human approval
179    #[serde(default)]
180    pub requires_approval: bool,
181
182    /// Requires tests for changes
183    #[serde(default)]
184    pub requires_tests: bool,
185
186    /// Requires documentation updates
187    #[serde(default)]
188    pub requires_docs: bool,
189
190    /// Maximum lines AI can change at once
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub max_lines_changed: Option<usize>,
193
194    /// Operations that are allowed
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub allowed_operations: Option<Vec<String>>,
197
198    /// Operations that are forbidden
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub forbidden_operations: Option<Vec<String>>,
201}
202
203/// @acp:summary "Lock level for code modification"
204#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "kebab-case")]
206pub enum LockLevel {
207    /// Cannot be modified under any circumstances
208    Frozen,
209    /// Requires explicit permission for any change
210    Restricted,
211    /// Changes need approval before applying
212    ApprovalRequired,
213    /// Changes must include tests
214    TestsRequired,
215    /// Changes must update docs
216    DocsRequired,
217    /// Changes require code review
218    ReviewRequired,
219    /// Normal - can be modified freely
220    #[default]
221    Normal,
222    /// Experimental - track all changes
223    Experimental,
224}
225
226/// @acp:summary "AI behavior modifiers"
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct BehaviorModifier {
229    /// General approach
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub approach: Option<Approach>,
232
233    /// What to optimize for
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub priority: Option<Priority>,
236
237    /// Must explain all changes
238    #[serde(default)]
239    pub explain: bool,
240
241    /// Make incremental changes
242    #[serde(default)]
243    pub step_by_step: bool,
244
245    /// Verify changes before finalizing
246    #[serde(default)]
247    pub verify: bool,
248
249    /// Ask permission before each change
250    #[serde(default)]
251    pub ask_first: bool,
252}
253
254/// @acp:summary "AI approach strategy"
255#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
256#[serde(rename_all = "lowercase")]
257pub enum Approach {
258    Conservative,
259    Aggressive,
260    Minimal,
261    Comprehensive,
262}
263
264/// @acp:summary "Optimization priority"
265#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
266#[serde(rename_all = "lowercase")]
267pub enum Priority {
268    Correctness,
269    Performance,
270    Readability,
271    Security,
272    Compatibility,
273}
274
275/// @acp:summary "Quality requirements"
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct QualityGate {
278    #[serde(default)]
279    pub tests_required: bool,
280
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub min_coverage: Option<f64>,
283
284    #[serde(default)]
285    pub security_review: bool,
286
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub max_complexity: Option<u32>,
289
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub accessibility: Option<String>,
292
293    #[serde(default)]
294    pub browser_support: Vec<String>,
295
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub performance_budget: Option<PerformanceBudget>,
298}
299
300/// @acp:summary "Performance budget constraints"
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct PerformanceBudget {
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub max_time_ms: Option<u64>,
305
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub max_memory_mb: Option<u64>,
308}
309
310/// @acp:summary "Deprecation information"
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct DeprecationInfo {
313    #[serde(default)]
314    pub deprecated: bool,
315
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub since: Option<String>,
318
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub removal_version: Option<String>,
321
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub replacement: Option<String>,
324
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub migration_guide: Option<String>,
327
328    #[serde(default)]
329    pub action: DeprecationAction,
330}
331
332/// @acp:summary "Deprecation action to take"
333#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
334#[serde(rename_all = "kebab-case")]
335pub enum DeprecationAction {
336    #[default]
337    Warn,
338    SuggestMigration,
339    AutoMigrate,
340    Block,
341}
342
343/// @acp:summary "Reference to documentation"
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct Reference {
346    pub url: String,
347
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub description: Option<String>,
350
351    /// Whether AI should fetch and read this
352    #[serde(default)]
353    pub fetch: bool,
354}
355
356/// @acp:summary "Experimental/hack marker"
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct HackMarker {
359    pub id: String,
360
361    #[serde(rename = "type")]
362    pub hack_type: HackType,
363
364    pub file: String,
365
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub line: Option<usize>,
368
369    pub created_at: DateTime<Utc>,
370
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub author: Option<String>,
373
374    pub reason: String,
375
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub ticket: Option<String>,
378
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub expires: Option<DateTime<Utc>>,
381
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub original_code: Option<String>,
384
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub revert_instructions: Option<String>,
387}
388
389impl HackMarker {
390    pub fn is_expired(&self) -> bool {
391        self.expires.map(|e| e < Utc::now()).unwrap_or(false)
392    }
393}
394
395/// @acp:summary "Type of hack/workaround"
396#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
397#[serde(rename_all = "lowercase")]
398pub enum HackType {
399    Hack,
400    Workaround,
401    Debug,
402    Experiment,
403    Temporary,
404    TestOnly,
405}
406
407/// @acp:summary "Debug session for tracking AI troubleshooting"
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct DebugSession {
410    pub id: String,
411    pub started_at: DateTime<Utc>,
412    pub problem: String,
413
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub hypothesis: Option<String>,
416
417    #[serde(default)]
418    pub attempts: Vec<DebugAttempt>,
419
420    #[serde(default)]
421    pub status: DebugStatus,
422
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub resolution: Option<String>,
425
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub resolved_at: Option<DateTime<Utc>>,
428}
429
430impl DebugSession {
431    pub fn new(id: impl Into<String>, problem: impl Into<String>) -> Self {
432        Self {
433            id: id.into(),
434            started_at: Utc::now(),
435            problem: problem.into(),
436            hypothesis: None,
437            attempts: Vec::new(),
438            status: DebugStatus::Active,
439            resolution: None,
440            resolved_at: None,
441        }
442    }
443
444    pub fn add_attempt(
445        &mut self,
446        hypothesis: impl Into<String>,
447        change: impl Into<String>,
448    ) -> usize {
449        let attempt_id = self.attempts.len() + 1;
450        self.attempts.push(DebugAttempt {
451            attempt_id,
452            timestamp: Utc::now(),
453            hypothesis: hypothesis.into(),
454            change: change.into(),
455            files_modified: Vec::new(),
456            diff: None,
457            result: DebugResult::Unknown,
458            observations: None,
459            keep: false,
460            reverted: false,
461        });
462        attempt_id
463    }
464
465    pub fn record_result(
466        &mut self,
467        attempt_id: usize,
468        result: DebugResult,
469        observations: Option<String>,
470    ) {
471        if let Some(attempt) = self
472            .attempts
473            .iter_mut()
474            .find(|a| a.attempt_id == attempt_id)
475        {
476            attempt.result = result;
477            attempt.observations = observations;
478
479            if result == DebugResult::Success {
480                attempt.keep = true;
481            }
482        }
483    }
484
485    pub fn revert_attempt(&mut self, attempt_id: usize) -> Option<&DebugAttempt> {
486        if let Some(attempt) = self
487            .attempts
488            .iter_mut()
489            .find(|a| a.attempt_id == attempt_id)
490        {
491            attempt.reverted = true;
492            attempt.keep = false;
493            return Some(attempt);
494        }
495        None
496    }
497
498    pub fn resolve(&mut self, resolution: impl Into<String>) {
499        self.status = DebugStatus::Resolved;
500        self.resolution = Some(resolution.into());
501        self.resolved_at = Some(Utc::now());
502    }
503
504    pub fn get_kept_attempts(&self) -> Vec<&DebugAttempt> {
505        self.attempts
506            .iter()
507            .filter(|a| a.keep && !a.reverted)
508            .collect()
509    }
510
511    pub fn get_reverted_attempts(&self) -> Vec<&DebugAttempt> {
512        self.attempts.iter().filter(|a| a.reverted).collect()
513    }
514}
515
516/// @acp:summary "Debug session status"
517#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
518#[serde(rename_all = "lowercase")]
519pub enum DebugStatus {
520    #[default]
521    Active,
522    Paused,
523    Resolved,
524    Abandoned,
525}
526
527/// @acp:summary "A single debug attempt"
528#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct DebugAttempt {
530    pub attempt_id: usize,
531    pub timestamp: DateTime<Utc>,
532    pub hypothesis: String,
533    pub change: String,
534
535    #[serde(default)]
536    pub files_modified: Vec<String>,
537
538    #[serde(skip_serializing_if = "Option::is_none")]
539    pub diff: Option<String>,
540
541    #[serde(default)]
542    pub result: DebugResult,
543
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub observations: Option<String>,
546
547    #[serde(default)]
548    pub keep: bool,
549
550    #[serde(default)]
551    pub reverted: bool,
552}
553
554/// @acp:summary "Debug attempt result"
555#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
556#[serde(rename_all = "lowercase")]
557pub enum DebugResult {
558    Success,
559    Failure,
560    Partial,
561    #[default]
562    Unknown,
563}
564
565/// @acp:summary "Constraint index in cache"
566#[derive(Debug, Clone, Default, Serialize, Deserialize)]
567pub struct ConstraintIndex {
568    /// Constraints by file path
569    #[serde(default)]
570    pub by_file: HashMap<String, Constraints>,
571
572    /// Active hack markers
573    #[serde(default)]
574    pub hacks: Vec<HackMarker>,
575
576    /// Active debug sessions
577    #[serde(default)]
578    pub debug_sessions: Vec<DebugSession>,
579
580    /// Files by lock level
581    #[serde(default)]
582    pub by_lock_level: HashMap<String, Vec<String>>,
583}
584
585impl ConstraintIndex {
586    /// Get effective constraints for a file (with inheritance)
587    pub fn get_effective(&self, file: &str, project_defaults: &Constraints) -> Constraints {
588        let file_constraints = self.by_file.get(file).cloned().unwrap_or_default();
589        project_defaults.merge(&file_constraints)
590    }
591
592    /// Get all expired hacks
593    pub fn get_expired_hacks(&self) -> Vec<&HackMarker> {
594        self.hacks.iter().filter(|h| h.is_expired()).collect()
595    }
596
597    /// Get active debug sessions
598    pub fn get_active_debug_sessions(&self) -> Vec<&DebugSession> {
599        self.debug_sessions
600            .iter()
601            .filter(|s| s.status == DebugStatus::Active)
602            .collect()
603    }
604
605    /// Get all frozen files
606    pub fn get_frozen_files(&self) -> Vec<&str> {
607        self.by_lock_level
608            .get("frozen")
609            .map(|v| v.iter().map(|s| s.as_str()).collect())
610            .unwrap_or_default()
611    }
612
613    /// Get all restricted files
614    pub fn get_restricted_files(&self) -> Vec<&str> {
615        self.by_lock_level
616            .get("restricted")
617            .map(|v| v.iter().map(|s| s.as_str()).collect())
618            .unwrap_or_default()
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    #[test]
627    fn test_constraint_merge() {
628        let base = Constraints {
629            style: Some(StyleConstraint {
630                guide: "google-typescript".to_string(),
631                reference: None,
632                rules: vec![],
633                linter: None,
634            }),
635            mutation: None,
636            behavior: None,
637            quality: None,
638            deprecation: None,
639            references: vec![],
640            directive: Some("Base directive".to_string()),
641            auto_generated: false,
642        };
643
644        let override_constraints = Constraints {
645            style: None,
646            mutation: Some(MutationConstraint {
647                level: LockLevel::Restricted,
648                reason: Some("Security".to_string()),
649                contact: None,
650                requires_approval: true,
651                requires_tests: false,
652                requires_docs: false,
653                max_lines_changed: None,
654                allowed_operations: None,
655                forbidden_operations: None,
656            }),
657            behavior: None,
658            quality: None,
659            deprecation: None,
660            references: vec![],
661            directive: Some("Override directive".to_string()),
662            auto_generated: false,
663        };
664
665        let merged = base.merge(&override_constraints);
666
667        // Style should be preserved from base
668        assert!(merged.style.is_some());
669        assert_eq!(merged.style.unwrap().guide, "google-typescript");
670
671        // Mutation should come from override
672        assert!(merged.mutation.is_some());
673        assert_eq!(merged.mutation.unwrap().level, LockLevel::Restricted);
674    }
675
676    #[test]
677    fn test_debug_session() {
678        let mut session = DebugSession::new("test-123", "Something is broken");
679
680        let attempt1 = session.add_attempt("Maybe it's X", "Changed X");
681        session.record_result(
682            attempt1,
683            DebugResult::Failure,
684            Some("Nope, not X".to_string()),
685        );
686
687        let attempt2 = session.add_attempt("Maybe it's Y", "Changed Y");
688        session.record_result(
689            attempt2,
690            DebugResult::Success,
691            Some("Yes, Y was the issue!".to_string()),
692        );
693
694        // Revert the failed attempt
695        session.revert_attempt(attempt1);
696
697        assert_eq!(session.get_kept_attempts().len(), 1);
698        assert_eq!(session.get_reverted_attempts().len(), 1);
699
700        session.resolve("Fixed by changing Y");
701        assert_eq!(session.status, DebugStatus::Resolved);
702    }
703}