acp/constraints/
guardrails.rs

1//! @acp:module "Guardrails"
2//! @acp:summary "Guardrail annotations for AI behavior control"
3//! @acp:domain cli
4//! @acp:layer model
5//!
6//! This module handles parsing, storage, and enforcement of guardrail annotations
7//! that control how AI systems interact with code.
8
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11
12/// @acp:summary "All guardrail annotations for a file"
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14pub struct FileGuardrails {
15    /// Constraint annotations
16    #[serde(default, skip_serializing_if = "GuardrailConstraints::is_empty")]
17    pub constraints: GuardrailConstraints,
18
19    /// AI behavior control
20    #[serde(default, skip_serializing_if = "AIBehavior::is_empty")]
21    pub ai_behavior: AIBehavior,
22
23    /// Temporary/experimental markers
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub temporary: Vec<TemporaryMarker>,
26
27    /// Active troubleshooting attempts
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub attempts: Vec<Attempt>,
30
31    /// Checkpoints for rollback
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub checkpoints: Vec<Checkpoint>,
34
35    /// Review requirements
36    #[serde(default, skip_serializing_if = "ReviewRequirements::is_empty")]
37    pub review: ReviewRequirements,
38
39    /// Quality markers
40    #[serde(default, skip_serializing_if = "QualityMarkers::is_empty")]
41    pub quality: QualityMarkers,
42}
43
44impl FileGuardrails {
45    pub fn is_empty(&self) -> bool {
46        self.constraints.is_empty()
47            && self.ai_behavior.is_empty()
48            && self.temporary.is_empty()
49            && self.attempts.is_empty()
50            && self.checkpoints.is_empty()
51            && self.review.is_empty()
52            && self.quality.is_empty()
53    }
54}
55
56// =============================================================================
57// Constraints (file-level guardrail constraints)
58// =============================================================================
59
60/// @acp:summary "File-level constraint annotations"
61#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62pub struct GuardrailConstraints {
63    /// Style guide (e.g., "tailwindcss-v4")
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub style: Option<StyleGuide>,
66
67    /// Framework requirements
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub frameworks: Vec<FrameworkRequirement>,
70
71    /// Compatibility requirements
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub compat: Vec<String>,
74
75    /// Hard requirements that must be maintained
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub requires: Vec<String>,
78
79    /// Explicitly forbidden patterns
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub forbids: Vec<String>,
82
83    /// Required patterns to follow
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub patterns: Vec<String>,
86
87    /// Linting rules
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub lint: Vec<String>,
90
91    /// Test requirements
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub test_required: Vec<String>,
94}
95
96impl GuardrailConstraints {
97    pub fn is_empty(&self) -> bool {
98        self.style.is_none()
99            && self.frameworks.is_empty()
100            && self.compat.is_empty()
101            && self.requires.is_empty()
102            && self.forbids.is_empty()
103            && self.patterns.is_empty()
104            && self.lint.is_empty()
105            && self.test_required.is_empty()
106    }
107}
108
109/// @acp:summary "Style guide reference"
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct StyleGuide {
112    pub name: String,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub url: Option<String>,
115}
116
117/// @acp:summary "Framework requirement"
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct FrameworkRequirement {
120    pub name: String,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub version: Option<String>,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub docs_url: Option<String>,
125}
126
127// =============================================================================
128// AI Behavior Control
129// =============================================================================
130
131/// @acp:summary "AI behavior control settings"
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133pub struct AIBehavior {
134    /// AI should not modify this code
135    #[serde(default)]
136    pub readonly: bool,
137
138    /// Reason for readonly
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub readonly_reason: Option<String>,
141
142    /// AI should be extra careful
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub careful: Vec<String>,
145
146    /// AI should ask before modifying
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub ask_before: Vec<String>,
149
150    /// Additional context for AI
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub context: Option<String>,
153
154    /// How AI should approach modifications
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub approach: Option<String>,
157
158    /// External references AI should consult
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub references: Vec<String>,
161}
162
163impl AIBehavior {
164    pub fn is_empty(&self) -> bool {
165        !self.readonly
166            && self.readonly_reason.is_none()
167            && self.careful.is_empty()
168            && self.ask_before.is_empty()
169            && self.context.is_none()
170            && self.approach.is_none()
171            && self.references.is_empty()
172    }
173}
174
175// =============================================================================
176// Temporary Markers
177// =============================================================================
178
179/// @acp:summary "Temporary code marker"
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct TemporaryMarker {
182    /// Type of temporary code
183    pub kind: TemporaryKind,
184
185    /// Description/reason
186    pub description: String,
187
188    /// Line number or range
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub lines: Option<[usize; 2]>,
191
192    /// Expiration condition
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub until: Option<String>,
195}
196
197/// @acp:summary "Kind of temporary code"
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "lowercase")]
200pub enum TemporaryKind {
201    Hack,
202    Experiment,
203    Debug,
204    Wip,
205    Temporary,
206}
207
208// =============================================================================
209// Troubleshooting Attempts
210// =============================================================================
211
212/// @acp:summary "Troubleshooting attempt"
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct Attempt {
215    /// Unique attempt identifier
216    pub id: String,
217
218    /// What issue this is attempting to fix
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub for_issue: Option<String>,
221
222    /// Description of the attempt
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub description: Option<String>,
225
226    /// Current status
227    pub status: AttemptStatus,
228
229    /// Failure reason if failed
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub failure_reason: Option<String>,
232
233    /// Lines affected
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub lines: Option<[usize; 2]>,
236
237    /// Original code (for revert)
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub original: Option<String>,
240
241    /// Conditions that should trigger revert
242    #[serde(default, skip_serializing_if = "Vec::is_empty")]
243    pub revert_if: Vec<String>,
244
245    /// Change reason
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub change_reason: Option<String>,
248
249    /// Timestamp
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub timestamp: Option<String>,
252}
253
254/// @acp:summary "Attempt status"
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "lowercase")]
257pub enum AttemptStatus {
258    Active,
259    Testing,
260    Failed,
261    Verified,
262    Reverted,
263}
264
265/// @acp:summary "Checkpoint for rollback"
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct Checkpoint {
268    /// Checkpoint name
269    pub name: String,
270
271    /// File hash at checkpoint
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub hash: Option<String>,
274
275    /// Description
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub description: Option<String>,
278
279    /// Timestamp
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub timestamp: Option<String>,
282}
283
284// =============================================================================
285// Review Requirements
286// =============================================================================
287
288/// @acp:summary "Review requirements for changes"
289#[derive(Debug, Clone, Default, Serialize, Deserialize)]
290pub struct ReviewRequirements {
291    /// Types of review required
292    #[serde(default, skip_serializing_if = "Vec::is_empty")]
293    pub required: Vec<String>,
294
295    /// Specific reviewers needed
296    #[serde(default, skip_serializing_if = "Vec::is_empty")]
297    pub reviewers: Vec<String>,
298
299    /// AI-generated code markers
300    #[serde(default, skip_serializing_if = "Vec::is_empty")]
301    pub ai_generated: Vec<AIGeneratedMarker>,
302
303    /// Human verification status
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub human_verified: Option<HumanVerification>,
306}
307
308impl ReviewRequirements {
309    pub fn is_empty(&self) -> bool {
310        self.required.is_empty()
311            && self.reviewers.is_empty()
312            && self.ai_generated.is_empty()
313            && self.human_verified.is_none()
314    }
315}
316
317/// @acp:summary "AI-generated code marker"
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct AIGeneratedMarker {
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub model: Option<String>,
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub date: Option<String>,
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub prompt: Option<String>,
326    #[serde(default)]
327    pub needs_review: bool,
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub lines: Option<[usize; 2]>,
330}
331
332/// @acp:summary "Human verification record"
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct HumanVerification {
335    pub verified: bool,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub by: Option<String>,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub date: Option<String>,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub notes: Option<String>,
342}
343
344// =============================================================================
345// Quality Markers
346// =============================================================================
347
348/// @acp:summary "Quality markers for code analysis"
349#[derive(Debug, Clone, Default, Serialize, Deserialize)]
350pub struct QualityMarkers {
351    /// Technical debt items
352    #[serde(default, skip_serializing_if = "Vec::is_empty")]
353    pub tech_debt: Vec<TechDebtItem>,
354
355    /// Complexity warnings
356    #[serde(default, skip_serializing_if = "Vec::is_empty")]
357    pub complexity: Vec<ComplexityMarker>,
358
359    /// Code smells
360    #[serde(default, skip_serializing_if = "Vec::is_empty")]
361    pub smells: Vec<String>,
362
363    /// Test coverage info
364    #[serde(skip_serializing_if = "Option::is_none")]
365    pub coverage: Option<String>,
366}
367
368impl QualityMarkers {
369    pub fn is_empty(&self) -> bool {
370        self.tech_debt.is_empty()
371            && self.complexity.is_empty()
372            && self.smells.is_empty()
373            && self.coverage.is_none()
374    }
375}
376
377/// @acp:summary "Technical debt item"
378#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct TechDebtItem {
380    pub description: String,
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub priority: Option<String>,
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub effort: Option<String>,
385}
386
387/// @acp:summary "Complexity marker"
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct ComplexityMarker {
390    pub level: String,
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub reason: Option<String>,
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub metric: Option<String>,
395}
396
397// =============================================================================
398// Parser
399// =============================================================================
400
401/// @acp:summary "Parses guardrail annotations from source code"
402pub struct GuardrailParser {
403    patterns: GuardrailPatterns,
404}
405
406struct GuardrailPatterns {
407    style: Regex,
408    framework: Regex,
409    requires: Regex,
410    forbids: Regex,
411    ai_readonly: Regex,
412    ai_careful: Regex,
413    ai_ask: Regex,
414    ai_context: Regex,
415    ai_reference: Regex,
416    hack: Regex,
417    attempt_start: Regex,
418    checkpoint: Regex,
419    review_required: Regex,
420    tech_debt: Regex,
421    test_required: Regex,
422}
423
424impl GuardrailParser {
425    pub fn new() -> Self {
426        Self {
427            patterns: GuardrailPatterns {
428                style: Regex::new(r"@acp:style\s+(\S+)(?:\s+(\S+))?").unwrap(),
429                framework: Regex::new(r"@acp:framework\s+(\S+)(?:@(\S+))?(?:\s+(\S+))?").unwrap(),
430                requires: Regex::new(r"@acp:requires\s+(.+)").unwrap(),
431                forbids: Regex::new(r"@acp:forbids\s+(.+)").unwrap(),
432                ai_readonly: Regex::new(r"@acp:ai-readonly(?:\s+reason:(.+))?").unwrap(),
433                ai_careful: Regex::new(r"@acp:ai-careful\s+(.+)").unwrap(),
434                ai_ask: Regex::new(r"@acp:ai-ask\s+(.+)").unwrap(),
435                ai_context: Regex::new(r"@acp:ai-context\s+(.+)").unwrap(),
436                ai_reference: Regex::new(r"@acp:ai-reference\s+(.+)").unwrap(),
437                hack: Regex::new(r"@acp:hack\s+(.+)").unwrap(),
438                attempt_start: Regex::new(
439                    r"@acp:attempt-start\s+id:(\S+)(?:\s+for:(\S+))?(?:\s+description:(.+))?",
440                )
441                .unwrap(),
442                checkpoint: Regex::new(r"@acp:checkpoint\s+name:(\S+)(?:\s+hash:(\S+))?").unwrap(),
443                review_required: Regex::new(r"@acp:review-required\s+(.+)").unwrap(),
444                tech_debt: Regex::new(r"@acp:tech-debt\s+(.+)").unwrap(),
445                test_required: Regex::new(r"@acp:test-required\s+(.+)").unwrap(),
446            },
447        }
448    }
449
450    /// Parse all guardrails from source content
451    pub fn parse(&self, content: &str) -> FileGuardrails {
452        let mut guardrails = FileGuardrails::default();
453
454        for (line_num, line) in content.lines().enumerate() {
455            self.parse_line(line, line_num + 1, &mut guardrails);
456        }
457
458        guardrails
459    }
460
461    fn parse_line(&self, line: &str, _line_num: usize, g: &mut FileGuardrails) {
462        // Style
463        if let Some(cap) = self.patterns.style.captures(line) {
464            g.constraints.style = Some(StyleGuide {
465                name: cap.get(1).unwrap().as_str().to_string(),
466                url: cap.get(2).map(|m| m.as_str().to_string()),
467            });
468        }
469
470        // Framework
471        if let Some(cap) = self.patterns.framework.captures(line) {
472            g.constraints.frameworks.push(FrameworkRequirement {
473                name: cap.get(1).unwrap().as_str().to_string(),
474                version: cap.get(2).map(|m| m.as_str().to_string()),
475                docs_url: cap.get(3).map(|m| m.as_str().to_string()),
476            });
477        }
478
479        // Requires
480        if let Some(cap) = self.patterns.requires.captures(line) {
481            let items: Vec<_> = cap
482                .get(1)
483                .unwrap()
484                .as_str()
485                .split(',')
486                .map(|s| s.trim().to_string())
487                .collect();
488            g.constraints.requires.extend(items);
489        }
490
491        // Forbids
492        if let Some(cap) = self.patterns.forbids.captures(line) {
493            let items: Vec<_> = cap
494                .get(1)
495                .unwrap()
496                .as_str()
497                .split(',')
498                .map(|s| s.trim().to_string())
499                .collect();
500            g.constraints.forbids.extend(items);
501        }
502
503        // AI Readonly
504        if let Some(cap) = self.patterns.ai_readonly.captures(line) {
505            g.ai_behavior.readonly = true;
506            g.ai_behavior.readonly_reason = cap.get(1).map(|m| m.as_str().to_string());
507        }
508
509        // AI Careful
510        if let Some(cap) = self.patterns.ai_careful.captures(line) {
511            g.ai_behavior
512                .careful
513                .push(cap.get(1).unwrap().as_str().to_string());
514        }
515
516        // AI Ask
517        if let Some(cap) = self.patterns.ai_ask.captures(line) {
518            g.ai_behavior
519                .ask_before
520                .push(cap.get(1).unwrap().as_str().to_string());
521        }
522
523        // AI Context
524        if let Some(cap) = self.patterns.ai_context.captures(line) {
525            let ctx = cap.get(1).unwrap().as_str().to_string();
526            if let Some(existing) = &mut g.ai_behavior.context {
527                existing.push('\n');
528                existing.push_str(&ctx);
529            } else {
530                g.ai_behavior.context = Some(ctx);
531            }
532        }
533
534        // AI Reference
535        if let Some(cap) = self.patterns.ai_reference.captures(line) {
536            g.ai_behavior
537                .references
538                .push(cap.get(1).unwrap().as_str().to_string());
539        }
540
541        // Hack
542        if let Some(cap) = self.patterns.hack.captures(line) {
543            g.temporary.push(TemporaryMarker {
544                kind: TemporaryKind::Hack,
545                description: cap.get(1).unwrap().as_str().to_string(),
546                lines: None,
547                until: None,
548            });
549        }
550
551        // Attempt
552        if let Some(cap) = self.patterns.attempt_start.captures(line) {
553            g.attempts.push(Attempt {
554                id: cap.get(1).unwrap().as_str().to_string(),
555                for_issue: cap.get(2).map(|m| m.as_str().to_string()),
556                description: cap.get(3).map(|m| m.as_str().to_string()),
557                status: AttemptStatus::Active,
558                failure_reason: None,
559                lines: None,
560                original: None,
561                revert_if: vec![],
562                change_reason: None,
563                timestamp: None,
564            });
565        }
566
567        // Checkpoint
568        if let Some(cap) = self.patterns.checkpoint.captures(line) {
569            g.checkpoints.push(Checkpoint {
570                name: cap.get(1).unwrap().as_str().to_string(),
571                hash: cap.get(2).map(|m| m.as_str().to_string()),
572                description: None,
573                timestamp: None,
574            });
575        }
576
577        // Review required
578        if let Some(cap) = self.patterns.review_required.captures(line) {
579            let items: Vec<_> = cap
580                .get(1)
581                .unwrap()
582                .as_str()
583                .split(',')
584                .map(|s| s.trim().to_string())
585                .collect();
586            g.review.required.extend(items);
587        }
588
589        // Tech debt
590        if let Some(cap) = self.patterns.tech_debt.captures(line) {
591            g.quality.tech_debt.push(TechDebtItem {
592                description: cap.get(1).unwrap().as_str().to_string(),
593                priority: None,
594                effort: None,
595            });
596        }
597
598        // Test required
599        if let Some(cap) = self.patterns.test_required.captures(line) {
600            let items: Vec<_> = cap
601                .get(1)
602                .unwrap()
603                .as_str()
604                .split(',')
605                .map(|s| s.trim().to_string())
606                .collect();
607            g.constraints.test_required.extend(items);
608        }
609    }
610}
611
612impl Default for GuardrailParser {
613    fn default() -> Self {
614        Self::new()
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn test_parse_readonly() {
624        let parser = GuardrailParser::new();
625        let content = "// @acp:ai-readonly reason:security-audited\nfunction secure() {}";
626        let guardrails = parser.parse(content);
627
628        assert!(guardrails.ai_behavior.readonly);
629        assert_eq!(
630            guardrails.ai_behavior.readonly_reason,
631            Some("security-audited".to_string())
632        );
633    }
634
635    #[test]
636    fn test_parse_forbids() {
637        let parser = GuardrailParser::new();
638        let content = "// @acp:forbids eval, Function, inline-styles";
639        let guardrails = parser.parse(content);
640
641        assert_eq!(guardrails.constraints.forbids.len(), 3);
642        assert!(guardrails.constraints.forbids.contains(&"eval".to_string()));
643    }
644}