Skip to main content

apcore_toolkit/
ai_enhancer.rs

1// AI-driven metadata enhancement using local SLMs.
2//
3// Uses an OpenAI-compatible local API (e.g., Ollama, vLLM, LM Studio) to fill
4// metadata gaps that static analysis cannot resolve.
5//
6// All AI-generated fields are tagged with `x-generated-by: slm` in the module's
7// metadata for auditability.
8
9use std::env;
10use std::time::Duration;
11
12use serde_json::{json, Value};
13use thiserror::Error;
14use tracing::warn;
15
16use apcore::module::ModuleAnnotations;
17
18use crate::types::ScannedModule;
19
20const DEFAULT_ENDPOINT: &str = "http://localhost:11434/v1";
21const DEFAULT_MODEL: &str = "qwen:0.6b";
22const DEFAULT_THRESHOLD: f64 = 0.7;
23const DEFAULT_BATCH_SIZE: usize = 5;
24const DEFAULT_TIMEOUT: u64 = 30;
25
26/// Errors returned by [`AIEnhancer`] operations.
27#[derive(Debug, Error)]
28pub enum AIEnhancerError {
29    /// Invalid configuration value.
30    #[error("invalid config: {0}")]
31    Config(String),
32    /// Failed to reach the SLM endpoint.
33    #[error("connection failed: {0}")]
34    Connection(String),
35    /// SLM returned an unparseable response.
36    #[error("bad response: {0}")]
37    Response(String),
38}
39
40/// Protocol for pluggable metadata enhancement.
41pub trait Enhancer {
42    /// Enhance a list of ScannedModules by filling metadata gaps.
43    fn enhance(&self, modules: Vec<ScannedModule>) -> Vec<ScannedModule>;
44}
45
46/// Enhances ScannedModule metadata using a local SLM.
47///
48/// Configuration is read from environment variables or constructor parameters:
49/// - `APCORE_AI_ENABLED`: Enable enhancement (default: false).
50/// - `APCORE_AI_ENDPOINT`: OpenAI-compatible API URL.
51/// - `APCORE_AI_MODEL`: Model name.
52/// - `APCORE_AI_THRESHOLD`: Confidence threshold (0.0–1.0).
53/// - `APCORE_AI_BATCH_SIZE`: Modules per API call.
54/// - `APCORE_AI_TIMEOUT`: Timeout in seconds per API call.
55#[derive(Debug)]
56pub struct AIEnhancer {
57    pub endpoint: String,
58    pub model: String,
59    pub threshold: f64,
60    pub batch_size: usize,
61    pub timeout: u64,
62}
63
64impl AIEnhancer {
65    /// Create a new AIEnhancer with optional overrides.
66    ///
67    /// Falls back to environment variables, then defaults.
68    pub fn new(
69        endpoint: Option<String>,
70        model: Option<String>,
71        threshold: Option<f64>,
72        batch_size: Option<usize>,
73        timeout: Option<u64>,
74    ) -> Result<Self, AIEnhancerError> {
75        let endpoint = endpoint.unwrap_or_else(|| {
76            env::var("APCORE_AI_ENDPOINT").unwrap_or_else(|_| DEFAULT_ENDPOINT.into())
77        });
78        let model = model.unwrap_or_else(|| {
79            env::var("APCORE_AI_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into())
80        });
81        let threshold =
82            threshold.unwrap_or_else(|| parse_float_env("APCORE_AI_THRESHOLD", DEFAULT_THRESHOLD));
83        let batch_size = batch_size
84            .unwrap_or_else(|| parse_usize_env("APCORE_AI_BATCH_SIZE", DEFAULT_BATCH_SIZE));
85        let timeout =
86            timeout.unwrap_or_else(|| parse_u64_env("APCORE_AI_TIMEOUT", DEFAULT_TIMEOUT));
87
88        if !(0.0..=1.0).contains(&threshold) {
89            return Err(AIEnhancerError::Config(
90                "APCORE_AI_THRESHOLD must be between 0.0 and 1.0".into(),
91            ));
92        }
93        if batch_size == 0 {
94            return Err(AIEnhancerError::Config(
95                "APCORE_AI_BATCH_SIZE must be a positive integer".into(),
96            ));
97        }
98        if timeout == 0 {
99            return Err(AIEnhancerError::Config(
100                "APCORE_AI_TIMEOUT must be a positive integer".into(),
101            ));
102        }
103
104        Ok(Self {
105            endpoint,
106            model,
107            threshold,
108            batch_size,
109            timeout,
110        })
111    }
112
113    /// Check whether AI enhancement is enabled via environment.
114    pub fn is_enabled() -> bool {
115        env::var("APCORE_AI_ENABLED")
116            .map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes"))
117            .unwrap_or(false)
118    }
119
120    /// Identify which metadata fields are missing or at defaults.
121    fn identify_gaps(&self, module: &ScannedModule) -> Vec<String> {
122        let mut gaps: Vec<String> = Vec::new();
123
124        if module.description.is_empty() || module.description == module.module_id {
125            gaps.push("description".into());
126        }
127        if module.documentation.is_none() {
128            gaps.push("documentation".into());
129        }
130        if module.annotations.is_none()
131            || module
132                .annotations
133                .as_ref()
134                .is_some_and(is_default_annotations)
135        {
136            gaps.push("annotations".into());
137        }
138        if module
139            .input_schema
140            .get("properties")
141            .and_then(|p| p.as_object())
142            .map(|o| o.is_empty())
143            .unwrap_or(true)
144        {
145            gaps.push("input_schema".into());
146        }
147
148        gaps
149    }
150
151    /// Build a structured prompt for the SLM.
152    fn build_prompt(&self, module: &ScannedModule, gaps: &[String]) -> String {
153        let mut parts = vec![
154            "You are analyzing a function to generate metadata for an AI-perceivable module system.".into(),
155            String::new(),
156            format!("Module ID: {}", module.module_id),
157            format!("Target: {}", module.target),
158        ];
159
160        if !module.description.is_empty() {
161            parts.push(format!("Current description: {}", module.description));
162        }
163
164        parts.push(String::new());
165        parts.push("Please provide the following missing metadata as JSON:".into());
166        parts.push("{".into());
167
168        for gap in gaps {
169            match gap.as_str() {
170                "description" => {
171                    parts.push(
172                        r#"  "description": "<≤200 chars, what this function does>","#.into(),
173                    );
174                }
175                "documentation" => {
176                    parts.push(r#"  "documentation": "<detailed Markdown explanation>","#.into());
177                }
178                "annotations" => {
179                    parts.push(r#"  "annotations": {"#.into());
180                    parts.push(r#"    "readonly": <true if no side effects>,"#.into());
181                    parts.push(r#"    "destructive": <true if deletes/overwrites data>,"#.into());
182                    parts.push(r#"    "idempotent": <true if safe to retry>,"#.into());
183                    parts.push(r#"    "requires_approval": <true if dangerous operation>,"#.into());
184                    parts.push(r#"    "open_world": <true if calls external systems>,"#.into());
185                    parts
186                        .push(r#"    "streaming": <true if yields results incrementally>,"#.into());
187                    parts.push(r#"    "cacheable": <true if results can be cached>,"#.into());
188                    parts.push(r#"    "cache_ttl": <seconds, 0 for no expiry>,"#.into());
189                    parts.push(r#"    "cache_key_fields": <list of input field names for cache key, or null for all>,"#.into());
190                    parts.push(r#"    "paginated": <true if supports pagination>,"#.into());
191                    parts
192                        .push(r#"    "pagination_style": <"cursor" or "offset" or "page">"#.into());
193                    parts.push("  },".into());
194                }
195                "input_schema" => {
196                    parts.push(
197                        r#"  "input_schema": <JSON Schema object for function parameters>,"#.into(),
198                    );
199                }
200                _ => {}
201            }
202        }
203
204        parts.push(r#"  "confidence": {"#.into());
205        parts.push(r#"    "description": 0.0, "documentation": 0.0"#.into());
206        parts.push("  }".into());
207        parts.push("}".into());
208        parts.push(String::new());
209        parts.push("Respond with ONLY valid JSON, no markdown fences or explanation.".into());
210
211        parts.join("\n")
212    }
213
214    /// Call the OpenAI-compatible API and return the response text.
215    fn call_llm(&self, prompt: &str) -> Result<String, AIEnhancerError> {
216        let url = format!("{}/chat/completions", self.endpoint.trim_end_matches('/'));
217        let payload = json!({
218            "model": self.model,
219            "messages": [{"role": "user", "content": prompt}],
220            "temperature": 0.1,
221        });
222
223        let agent = ureq::Agent::config_builder()
224            .timeout_global(Some(Duration::from_secs(self.timeout)))
225            .build()
226            .new_agent();
227
228        let body: Value = agent
229            .post(&url)
230            .header("Content-Type", "application/json")
231            .send_json(&payload)
232            .map_err(|e| AIEnhancerError::Connection(format!("Failed to reach SLM at {url}: {e}")))?
233            .body_mut()
234            .read_json()
235            .map_err(|e| AIEnhancerError::Response(format!("Failed to parse SLM response: {e}")))?;
236
237        body["choices"][0]["message"]["content"]
238            .as_str()
239            .map(|s| s.to_string())
240            .ok_or_else(|| AIEnhancerError::Response("Unexpected API response structure".into()))
241    }
242
243    /// Parse the SLM response as JSON, stripping markdown fences if present.
244    fn parse_response(response: &str) -> Result<Value, AIEnhancerError> {
245        let mut text = response.trim().to_string();
246
247        // Strip markdown code fences
248        if text.starts_with("```") {
249            let lines: Vec<&str> = text.split('\n').collect();
250            let start = if lines[0].starts_with("```") { 1 } else { 0 };
251            let end = if lines.last().map(|l| l.trim()) == Some("```") {
252                lines.len() - 1
253            } else {
254                lines.len()
255            };
256            text = lines[start..end].join("\n");
257        }
258
259        serde_json::from_str(&text)
260            .map_err(|e| AIEnhancerError::Response(format!("SLM returned invalid JSON: {e}")))
261    }
262
263    /// Enhance a single module by calling the SLM.
264    fn enhance_module(
265        &self,
266        module: &ScannedModule,
267        gaps: &[String],
268    ) -> Result<ScannedModule, AIEnhancerError> {
269        let prompt = self.build_prompt(module, gaps);
270        let response = self.call_llm(&prompt)?;
271        let parsed = Self::parse_response(&response)?;
272
273        let mut result = module.clone();
274        let mut confidence: serde_json::Map<String, Value> = serde_json::Map::new();
275
276        // Apply description
277        if gaps.iter().any(|g| g == "description") {
278            if let Some(desc) = parsed.get("description").and_then(|v| v.as_str()) {
279                let conf = parsed
280                    .get("confidence")
281                    .and_then(|c| c.get("description"))
282                    .and_then(|v| v.as_f64())
283                    .unwrap_or(0.0);
284                confidence.insert("description".into(), json!(conf));
285                if conf >= self.threshold {
286                    result.description = desc.to_string();
287                } else {
288                    result.warnings.push(format!(
289                        "Low confidence ({conf:.2}) for description — skipped. Review manually."
290                    ));
291                }
292            }
293        }
294
295        // Apply documentation
296        if gaps.iter().any(|g| g == "documentation") {
297            if let Some(doc) = parsed.get("documentation").and_then(|v| v.as_str()) {
298                let conf = parsed
299                    .get("confidence")
300                    .and_then(|c| c.get("documentation"))
301                    .and_then(|v| v.as_f64())
302                    .unwrap_or(0.0);
303                confidence.insert("documentation".into(), json!(conf));
304                if conf >= self.threshold {
305                    result.documentation = Some(doc.to_string());
306                } else {
307                    result.warnings.push(format!(
308                        "Low confidence ({conf:.2}) for documentation — skipped. Review manually."
309                    ));
310                }
311            }
312        }
313
314        // Apply annotations if above threshold (per-field confidence)
315        if gaps.iter().any(|g| g == "annotations") {
316            if let Some(ann_data) = parsed.get("annotations").and_then(|v| v.as_object()) {
317                let ann_conf = parsed
318                    .get("confidence")
319                    .and_then(|v| v.as_object())
320                    .cloned()
321                    .unwrap_or_default();
322                let mut base = module.annotations.clone().unwrap_or_default();
323                let mut any_accepted = false;
324
325                // Boolean fields enumerated explicitly because Rust has no
326                // runtime reflection over `ModuleAnnotations`. Keep this list
327                // (and `set_bool_annotation` below) in sync with
328                // `apcore::module::ModuleAnnotations`. The integer/string/list
329                // branches further down also need updating when upstream adds
330                // new fields. The `extra` field is intentionally excluded —
331                // it is reserved for adapter extensions, not SLM judgement.
332                let bool_fields = [
333                    "readonly",
334                    "destructive",
335                    "idempotent",
336                    "requires_approval",
337                    "open_world",
338                    "streaming",
339                    "cacheable",
340                    "paginated",
341                ];
342                for field in &bool_fields {
343                    if let Some(val) = ann_data.get(*field).and_then(|v| v.as_bool()) {
344                        let field_conf = get_annotation_confidence(&ann_conf, field);
345                        confidence.insert(format!("annotations.{field}"), json!(field_conf));
346                        if field_conf >= self.threshold {
347                            set_bool_annotation(&mut base, field, val);
348                            any_accepted = true;
349                        } else {
350                            result.warnings.push(format!(
351                                "Low confidence ({field_conf:.2}) for annotations.{field} — skipped. Review manually."
352                            ));
353                        }
354                    }
355                }
356
357                // Integer fields: cache_ttl
358                if let Some(val) = ann_data.get("cache_ttl").and_then(|v| v.as_u64()) {
359                    let field_conf = get_annotation_confidence(&ann_conf, "cache_ttl");
360                    confidence.insert("annotations.cache_ttl".into(), json!(field_conf));
361                    if field_conf >= self.threshold {
362                        base.cache_ttl = val;
363                        any_accepted = true;
364                    } else {
365                        result.warnings.push(format!(
366                            "Low confidence ({field_conf:.2}) for annotations.cache_ttl — skipped. Review manually."
367                        ));
368                    }
369                }
370
371                // String fields: pagination_style
372                if let Some(val) = ann_data.get("pagination_style").and_then(|v| v.as_str()) {
373                    let field_conf = get_annotation_confidence(&ann_conf, "pagination_style");
374                    confidence.insert("annotations.pagination_style".into(), json!(field_conf));
375                    if field_conf >= self.threshold {
376                        base.pagination_style = val.to_string();
377                        any_accepted = true;
378                    } else {
379                        result.warnings.push(format!(
380                            "Low confidence ({field_conf:.2}) for annotations.pagination_style — skipped. Review manually."
381                        ));
382                    }
383                }
384
385                // List fields: cache_key_fields
386                if let Some(arr) = ann_data.get("cache_key_fields").and_then(|v| v.as_array()) {
387                    let field_conf = get_annotation_confidence(&ann_conf, "cache_key_fields");
388                    confidence.insert("annotations.cache_key_fields".into(), json!(field_conf));
389                    if field_conf >= self.threshold {
390                        let keys: Vec<String> = arr
391                            .iter()
392                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
393                            .collect();
394                        base.cache_key_fields = Some(keys);
395                        any_accepted = true;
396                    } else {
397                        result.warnings.push(format!(
398                            "Low confidence ({field_conf:.2}) for annotations.cache_key_fields — skipped. Review manually."
399                        ));
400                    }
401                }
402
403                if any_accepted {
404                    result.annotations = Some(base);
405                }
406            }
407        }
408
409        // Apply input_schema if above threshold
410        if gaps.iter().any(|g| g == "input_schema") {
411            if let Some(schema) = parsed.get("input_schema") {
412                let conf = parsed
413                    .get("confidence")
414                    .and_then(|c| c.get("input_schema"))
415                    .and_then(|v| v.as_f64())
416                    .unwrap_or(0.0);
417                confidence.insert("input_schema".into(), json!(conf));
418                if conf >= self.threshold {
419                    result.input_schema = schema.clone();
420                } else {
421                    result.warnings.push(format!(
422                        "Low confidence ({conf:.2}) for input_schema — skipped. Review manually."
423                    ));
424                }
425            }
426        }
427
428        // Tag AI-generated fields
429        if !confidence.is_empty() {
430            result
431                .metadata
432                .insert("x-generated-by".into(), Value::String("slm".into()));
433            result
434                .metadata
435                .insert("x-ai-confidence".into(), Value::Object(confidence));
436        }
437
438        Ok(result)
439    }
440}
441
442impl Enhancer for AIEnhancer {
443    fn enhance(&self, modules: Vec<ScannedModule>) -> Vec<ScannedModule> {
444        let mut results: Vec<ScannedModule> = Vec::with_capacity(modules.len());
445
446        let mut pending: Vec<(usize, Vec<String>)> = Vec::new();
447        for (idx, module) in modules.iter().enumerate() {
448            let gaps = self.identify_gaps(module);
449            results.push(module.clone());
450            if !gaps.is_empty() {
451                pending.push((idx, gaps));
452            }
453        }
454
455        for batch in pending.chunks(self.batch_size) {
456            for (idx, gaps) in batch {
457                match self.enhance_module(&modules[*idx], gaps) {
458                    Ok(enhanced) => results[*idx] = enhanced,
459                    Err(e) => {
460                        warn!("AI enhancement failed for {}: {e}", modules[*idx].module_id);
461                    }
462                }
463            }
464        }
465
466        results
467    }
468}
469
470/// Check whether annotations are at their default values.
471///
472/// Uses `serde_json` round-trip equality so the comparison automatically
473/// covers any new field added to `apcore::module::ModuleAnnotations` upstream
474/// (including the `extra` extension map). `ModuleAnnotations` does not
475/// implement `PartialEq`, so direct `==` is unavailable.
476fn is_default_annotations(ann: &ModuleAnnotations) -> bool {
477    match (
478        serde_json::to_value(ann),
479        serde_json::to_value(ModuleAnnotations::default()),
480    ) {
481        (Ok(a), Ok(b)) => a == b,
482        _ => false,
483    }
484}
485
486/// Get confidence for an annotation field, checking both `annotations.<field>` and `<field>` keys.
487fn get_annotation_confidence(conf: &serde_json::Map<String, Value>, field: &str) -> f64 {
488    conf.get(&format!("annotations.{field}"))
489        .or_else(|| conf.get(field))
490        .and_then(|v| v.as_f64())
491        .unwrap_or(0.0)
492}
493
494/// Set a boolean field on `ModuleAnnotations` by name.
495fn set_bool_annotation(ann: &mut ModuleAnnotations, field: &str, value: bool) {
496    match field {
497        "readonly" => ann.readonly = value,
498        "destructive" => ann.destructive = value,
499        "idempotent" => ann.idempotent = value,
500        "requires_approval" => ann.requires_approval = value,
501        "open_world" => ann.open_world = value,
502        "streaming" => ann.streaming = value,
503        "cacheable" => ann.cacheable = value,
504        "paginated" => ann.paginated = value,
505        _ => {}
506    }
507}
508
509fn parse_float_env(name: &str, default: f64) -> f64 {
510    env::var(name)
511        .ok()
512        .and_then(|v| v.parse().ok())
513        .unwrap_or(default)
514}
515
516fn parse_usize_env(name: &str, default: usize) -> usize {
517    env::var(name)
518        .ok()
519        .and_then(|v| v.parse().ok())
520        .unwrap_or(default)
521}
522
523fn parse_u64_env(name: &str, default: u64) -> u64 {
524    env::var(name)
525        .ok()
526        .and_then(|v| v.parse().ok())
527        .unwrap_or(default)
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use apcore::module::ModuleAnnotations;
534    use serde_json::json;
535
536    #[test]
537    fn test_ai_enhancer_new_defaults() {
538        let enhancer = AIEnhancer::new(None, None, None, None, None).unwrap();
539        assert_eq!(enhancer.endpoint, DEFAULT_ENDPOINT);
540        assert_eq!(enhancer.model, DEFAULT_MODEL);
541        assert!((enhancer.threshold - DEFAULT_THRESHOLD).abs() < f64::EPSILON);
542        assert_eq!(enhancer.batch_size, DEFAULT_BATCH_SIZE);
543        assert_eq!(enhancer.timeout, DEFAULT_TIMEOUT);
544    }
545
546    #[test]
547    fn test_ai_enhancer_new_with_overrides() {
548        let enhancer = AIEnhancer::new(
549            Some("http://custom:8080".into()),
550            Some("llama3".into()),
551            Some(0.5),
552            Some(10),
553            Some(60),
554        )
555        .unwrap();
556        assert_eq!(enhancer.endpoint, "http://custom:8080");
557        assert_eq!(enhancer.model, "llama3");
558        assert!((enhancer.threshold - 0.5).abs() < f64::EPSILON);
559    }
560
561    #[test]
562    fn test_ai_enhancer_threshold_validation() {
563        let result = AIEnhancer::new(None, None, Some(1.5), None, None);
564        assert!(result.is_err());
565    }
566
567    #[test]
568    fn test_ai_enhancer_batch_size_validation() {
569        let result = AIEnhancer::new(None, None, None, Some(0), None);
570        assert!(result.is_err());
571    }
572
573    #[test]
574    fn test_identify_gaps_complete_module() {
575        let enhancer = AIEnhancer::new(None, None, None, None, None).unwrap();
576        let mut module = ScannedModule::new(
577            "test".into(),
578            "A real description".into(),
579            json!({"type": "object", "properties": {"x": {"type": "string"}}}),
580            json!({}),
581            vec![],
582            "app:func".into(),
583        );
584        module.documentation = Some("Full docs".into());
585        module.annotations = Some(ModuleAnnotations {
586            readonly: true,
587            ..Default::default()
588        });
589        let gaps = enhancer.identify_gaps(&module);
590        assert!(gaps.is_empty());
591    }
592
593    #[test]
594    fn test_identify_gaps_missing_fields() {
595        let enhancer = AIEnhancer::new(None, None, None, None, None).unwrap();
596        let module = ScannedModule::new(
597            "test".into(),
598            String::new(),
599            json!({"type": "object"}),
600            json!({}),
601            vec![],
602            "app:func".into(),
603        );
604        let gaps = enhancer.identify_gaps(&module);
605        assert!(gaps.iter().any(|g| g == "description"));
606        assert!(gaps.iter().any(|g| g == "documentation"));
607        assert!(gaps.iter().any(|g| g == "annotations"));
608        assert!(gaps.iter().any(|g| g == "input_schema"));
609    }
610
611    #[test]
612    fn test_parse_response_valid_json() {
613        let response = r#"{"description": "hello", "confidence": {"description": 0.9}}"#;
614        let result = AIEnhancer::parse_response(response).unwrap();
615        assert_eq!(result["description"], "hello");
616    }
617
618    #[test]
619    fn test_parse_response_with_fences() {
620        let response = "```json\n{\"key\": \"value\"}\n```";
621        let result = AIEnhancer::parse_response(response).unwrap();
622        assert_eq!(result["key"], "value");
623    }
624
625    #[test]
626    fn test_parse_response_invalid() {
627        let result = AIEnhancer::parse_response("not json");
628        assert!(result.is_err());
629    }
630
631    #[test]
632    fn test_is_enabled_default() {
633        // Assuming env var is not set in test environment
634        env::remove_var("APCORE_AI_ENABLED");
635        assert!(!AIEnhancer::is_enabled());
636    }
637
638    #[test]
639    fn test_build_prompt_contains_module_info() {
640        let enhancer = AIEnhancer::new(None, None, None, None, None).unwrap();
641        let module = ScannedModule::new(
642            "users.get".into(),
643            "Get user".into(),
644            json!({}),
645            json!({}),
646            vec![],
647            "app:get_user".into(),
648        );
649        let prompt = enhancer.build_prompt(&module, &["description".into()]);
650        assert!(prompt.contains("users.get"));
651        assert!(prompt.contains("app:get_user"));
652        assert!(prompt.contains("description"));
653    }
654
655    #[test]
656    fn test_identify_gaps_description_equals_module_id() {
657        let enhancer = AIEnhancer::new(None, None, None, None, None).unwrap();
658        let module = ScannedModule::new(
659            "my_module".into(),
660            "my_module".into(), // description == module_id
661            json!({"type": "object", "properties": {"x": {"type": "string"}}}),
662            json!({}),
663            vec![],
664            "app:func".into(),
665        );
666        let gaps = enhancer.identify_gaps(&module);
667        assert!(
668            gaps.iter().any(|g| g == "description"),
669            "description matching module_id should be identified as a gap"
670        );
671    }
672
673    #[test]
674    fn test_ai_enhancer_timeout_validation() {
675        let result = AIEnhancer::new(None, None, None, None, Some(0));
676        assert!(result.is_err());
677        let err = result.unwrap_err();
678        assert!(err
679            .to_string()
680            .contains("APCORE_AI_TIMEOUT must be a positive integer"));
681    }
682
683    // All is_enabled tests are combined into one to prevent env var races
684    // when tests run in parallel (env vars are process-global).
685    #[test]
686    fn test_is_enabled_variants() {
687        use std::sync::Mutex;
688        static ENV_LOCK: Mutex<()> = Mutex::new(());
689        let _guard = ENV_LOCK.lock().unwrap();
690
691        // Default (unset) → disabled
692        unsafe { env::remove_var("APCORE_AI_ENABLED") };
693        assert!(!AIEnhancer::is_enabled(), "should be disabled by default");
694
695        // "true" → enabled
696        unsafe { env::set_var("APCORE_AI_ENABLED", "true") };
697        assert!(AIEnhancer::is_enabled(), "\"true\" should enable");
698
699        // "yes" → enabled
700        unsafe { env::set_var("APCORE_AI_ENABLED", "yes") };
701        assert!(AIEnhancer::is_enabled(), "\"yes\" should enable");
702
703        // "1" → enabled
704        unsafe { env::set_var("APCORE_AI_ENABLED", "1") };
705        assert!(AIEnhancer::is_enabled(), "\"1\" should enable");
706
707        // "false" → disabled
708        unsafe { env::set_var("APCORE_AI_ENABLED", "false") };
709        assert!(!AIEnhancer::is_enabled(), "\"false\" should disable");
710
711        // Cleanup
712        unsafe { env::remove_var("APCORE_AI_ENABLED") };
713    }
714
715    #[test]
716    fn test_parse_response_strips_json_fence() {
717        let response = "```json\n{\"description\": \"hello world\"}\n```";
718        let result = AIEnhancer::parse_response(response).unwrap();
719        assert_eq!(result["description"], "hello world");
720    }
721
722    #[test]
723    fn test_build_prompt_requests_annotations() {
724        let enhancer = AIEnhancer::new(None, None, None, None, None).unwrap();
725        let module = ScannedModule::new(
726            "test".into(),
727            "desc".into(),
728            json!({}),
729            json!({}),
730            vec![],
731            "app:func".into(),
732        );
733        let prompt = enhancer.build_prompt(&module, &["annotations".into()]);
734        assert!(
735            prompt.contains("readonly"),
736            "prompt should mention annotations fields"
737        );
738        assert!(prompt.contains("destructive"));
739        assert!(prompt.contains("idempotent"));
740    }
741
742    #[test]
743    fn test_build_prompt_requests_input_schema() {
744        let enhancer = AIEnhancer::new(None, None, None, None, None).unwrap();
745        let module = ScannedModule::new(
746            "test".into(),
747            "desc".into(),
748            json!({}),
749            json!({}),
750            vec![],
751            "app:func".into(),
752        );
753        let prompt = enhancer.build_prompt(&module, &["input_schema".into()]);
754        assert!(
755            prompt.contains("input_schema"),
756            "prompt should mention input_schema"
757        );
758        assert!(prompt.contains("JSON Schema"));
759    }
760
761    #[test]
762    fn test_build_prompt_requests_documentation() {
763        let enhancer = AIEnhancer::new(None, None, None, None, None).unwrap();
764        let module = ScannedModule::new(
765            "test".into(),
766            "desc".into(),
767            json!({}),
768            json!({}),
769            vec![],
770            "app:func".into(),
771        );
772        let prompt = enhancer.build_prompt(&module, &["documentation".into()]);
773        assert!(
774            prompt.contains("documentation"),
775            "prompt should mention documentation"
776        );
777        assert!(prompt.contains("Markdown"));
778    }
779}