agnix-core 0.17.0

Core validation engine for agent configurations
Documentation
//! GitHub Copilot reusable prompt schema helpers.
//!
//! Supports `.github/prompts/*.prompt.md` files with optional YAML frontmatter.

use serde::{Deserialize, Serialize};

/// Known valid keys for prompt frontmatter.
pub const KNOWN_KEYS: &[&str] = &[
    "description",
    "name",
    "argument-hint",
    "agent",
    "model",
    "tools",
];

/// Valid `agent` values for prompt frontmatter.
pub const VALID_AGENT_MODES: &[&str] = &["none", "ask", "always"];

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct CopilotPromptSchema {
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub argument_hint: Option<serde_yaml::Value>,
    #[serde(default)]
    pub agent: Option<String>,
    #[serde(default)]
    pub model: Option<serde_yaml::Value>,
    #[serde(default)]
    pub tools: Option<serde_yaml::Value>,
}

#[derive(Debug, Clone)]
pub struct ParsedPromptFrontmatter {
    pub schema: Option<CopilotPromptSchema>,
    pub raw: String,
    pub start_line: usize,
    pub end_line: usize,
    pub body: String,
    pub unknown_keys: Vec<UnknownKey>,
    pub parse_error: Option<String>,
}

#[derive(Debug, Clone)]
pub struct UnknownKey {
    pub key: String,
    pub line: usize,
    pub column: usize,
}

impl crate::rules::FrontmatterRanges for ParsedPromptFrontmatter {
    fn raw_content(&self) -> &str {
        &self.raw
    }
    fn start_line(&self) -> usize {
        self.start_line
    }
}

/// Parse optional frontmatter from `.prompt.md` content.
pub fn parse_prompt_frontmatter(content: &str) -> Option<ParsedPromptFrontmatter> {
    if !content.starts_with("---") {
        return None;
    }

    let lines: Vec<&str> = content.lines().collect();
    if lines.is_empty() {
        return None;
    }

    let mut end_idx = None;
    let mut min_key_indent: Option<usize> = None;
    for (i, line) in lines.iter().enumerate().skip(1) {
        let trimmed_start = line.trim_start();
        if !trimmed_start.is_empty() && !trimmed_start.starts_with('#') {
            if let Some(colon_idx) = trimmed_start.find(':') {
                let key = trimmed_start[..colon_idx].trim();
                if !key.is_empty() {
                    let indent = line.len() - trimmed_start.len();
                    min_key_indent = Some(match min_key_indent {
                        Some(existing) => existing.min(indent),
                        None => indent,
                    });
                }
            }
        }

        if line.trim() == "---" {
            let indent = line.len() - trimmed_start.len();
            let can_close = min_key_indent.is_none_or(|key_indent| indent <= key_indent);
            if can_close {
                end_idx = Some(i);
                break;
            }
        }
    }

    if end_idx.is_none() {
        let raw = lines[1..].join("\n");
        return Some(ParsedPromptFrontmatter {
            schema: None,
            raw,
            start_line: 1,
            end_line: lines.len(),
            body: String::new(),
            unknown_keys: Vec::new(),
            parse_error: Some("missing closing ---".to_string()),
        });
    }

    let end_idx = end_idx.expect("checked is_some above");
    let raw = lines[1..end_idx].join("\n");
    let body = lines[end_idx + 1..].join("\n");

    let (schema, parse_error) = match serde_yaml::from_str::<CopilotPromptSchema>(&raw) {
        Ok(s) => (Some(s), None),
        Err(e) => (None, Some(e.to_string())),
    };

    let unknown_keys = find_unknown_keys(&raw, 2);

    Some(ParsedPromptFrontmatter {
        schema,
        raw,
        start_line: 1,
        end_line: end_idx + 1,
        body,
        unknown_keys,
        parse_error,
    })
}

fn find_unknown_keys(yaml: &str, start_line: usize) -> Vec<UnknownKey> {
    let mut unknown = Vec::new();
    let top_level_indent = yaml
        .lines()
        .filter_map(|line| {
            let trimmed = line.trim_start();
            if trimmed.is_empty() || trimmed.starts_with('#') {
                return None;
            }
            let colon_idx = trimmed.find(':')?;
            let key = trimmed[..colon_idx].trim();
            if key.is_empty() {
                return None;
            }
            Some(line.len() - trimmed.len())
        })
        .min()
        .unwrap_or(0);

    for (i, line) in yaml.lines().enumerate() {
        let trimmed = line.trim_start();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }
        let indent = line.len() - trimmed.len();
        if indent != top_level_indent {
            continue;
        }

        if let Some(colon_idx) = trimmed.find(':') {
            let key_raw = &trimmed[..colon_idx];
            let key = key_raw.trim().trim_matches(|c| c == '\'' || c == '"');

            if !key.is_empty() && !KNOWN_KEYS.contains(&key) {
                unknown.push(UnknownKey {
                    key: key.to_string(),
                    line: start_line + i,
                    column: indent,
                });
            }
        }
    }

    unknown
}

pub fn is_body_empty(body: &str) -> bool {
    body.trim().is_empty()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_valid_prompt_frontmatter() {
        let content = r#"---
description: Summarize current file
agent: ask
---
Summarize the current file.
"#;
        let parsed = parse_prompt_frontmatter(content).expect("expected frontmatter");
        assert!(parsed.parse_error.is_none());
        let schema = parsed.schema.expect("expected schema");
        assert_eq!(schema.agent.as_deref(), Some("ask"));
        assert!(parsed.unknown_keys.is_empty());
    }

    #[test]
    fn parse_unknown_keys() {
        let content = r#"---
description: test
weird-key: yes
---
Body
"#;
        let parsed = parse_prompt_frontmatter(content).expect("expected frontmatter");
        assert_eq!(parsed.unknown_keys.len(), 1);
        assert_eq!(parsed.unknown_keys[0].key, "weird-key");
    }

    #[test]
    fn parse_unknown_keys_with_uniform_indentation() {
        let content = r#"---
 description: test
 weird-key: yes
 --- 
Body
"#;
        let parsed = parse_prompt_frontmatter(content).expect("expected frontmatter");
        assert_eq!(parsed.unknown_keys.len(), 1);
        assert_eq!(parsed.unknown_keys[0].key, "weird-key");
    }

    #[test]
    fn parse_ignores_comment_lines() {
        let content = r#"---
description: test
# agent: ask
---
Body
"#;
        let parsed = parse_prompt_frontmatter(content).expect("expected frontmatter");
        assert!(
            parsed.unknown_keys.is_empty(),
            "comments should not be treated as unknown keys"
        );
    }

    #[test]
    fn parse_handles_indented_fence_in_block_scalar() {
        let content = r#"---
description: |
  Keep this separator literal:
  ---
agent: ask
---
Body
"#;
        let parsed = parse_prompt_frontmatter(content).expect("expected frontmatter");
        assert!(
            parsed.parse_error.is_none(),
            "indented '---' should not terminate frontmatter"
        );
        let schema = parsed.schema.expect("expected schema");
        assert_eq!(schema.agent.as_deref(), Some("ask"));
    }

    #[test]
    fn parse_no_frontmatter_returns_none() {
        assert!(parse_prompt_frontmatter("Prompt body").is_none());
    }

    #[test]
    fn valid_agent_modes_constant() {
        assert!(VALID_AGENT_MODES.contains(&"none"));
        assert!(VALID_AGENT_MODES.contains(&"ask"));
        assert!(VALID_AGENT_MODES.contains(&"always"));
    }

    #[test]
    fn body_empty_helper() {
        assert!(is_body_empty(" \n"));
        assert!(!is_body_empty("x"));
    }
}