sac-cli 0.1.0

Terminal-based AI coding agent — fork of NAC with extended backend support and context management
Documentation
use super::*;

#[derive(Debug, Deserialize)]
pub(super) struct SkillFrontmatter {
    pub(super) name: String,
    pub(super) description: Option<String>,
    pub(super) compatibility: Option<String>,
    #[allow(dead_code)]
    license: Option<String>,
    #[allow(dead_code)]
    metadata: Option<serde_yaml::Value>,
    #[allow(dead_code)]
    #[serde(rename = "allowed-tools")]
    allowed_tools: Option<serde_yaml::Value>,
}

pub(super) struct ParsedSkillFile {
    pub(super) name: String,
    pub(super) description: String,
    pub(super) compatibility: Option<String>,
    pub(super) body: String,
}

pub(super) fn parse_skill_file(path: &Path) -> Result<Option<ParsedSkillFile>> {
    let raw = fs::read_to_string(path)
        .with_context(|| format!("failed to read skill file '{}'", path.display()))?;
    let Some((frontmatter_raw, body_raw)) = split_frontmatter(&raw) else {
        eprintln!(
            "Skill file '{}' is missing YAML frontmatter and will be skipped",
            path.display()
        );
        return Ok(None);
    };

    let frontmatter = match parse_frontmatter(frontmatter_raw) {
        Ok(frontmatter) => frontmatter,
        Err(error) => {
            eprintln!(
                "Skill file '{}' has invalid frontmatter and will be skipped: {:#}",
                path.display(),
                error
            );
            return Ok(None);
        }
    };

    let name = frontmatter.name.trim().to_string();
    if name.is_empty() {
        eprintln!(
            "Skill file '{}' has an empty name and will be skipped",
            path.display()
        );
        return Ok(None);
    }

    if let Some(parent) = path
        .parent()
        .and_then(|value| value.file_name())
        .and_then(|value| value.to_str())
    {
        if parent != name {
            eprintln!(
                "Skill '{}' in '{}' does not match parent directory name '{}'; loading anyway",
                name,
                path.display(),
                parent
            );
        }
    }

    let Some(description) = frontmatter
        .description
        .map(|value| value.trim().to_string())
    else {
        eprintln!(
            "Skill file '{}' is missing a description and will be skipped",
            path.display()
        );
        return Ok(None);
    };
    if description.is_empty() {
        eprintln!(
            "Skill file '{}' has an empty description and will be skipped",
            path.display()
        );
        return Ok(None);
    }

    let body = body_raw.trim().to_string();
    if body.is_empty() {
        eprintln!(
            "Skill file '{}' has no body content and will be skipped",
            path.display()
        );
        return Ok(None);
    }

    Ok(Some(ParsedSkillFile {
        name,
        description,
        compatibility: frontmatter
            .compatibility
            .map(|value| value.trim().to_string()),
        body,
    }))
}

pub(super) fn parse_frontmatter(raw: &str) -> Result<SkillFrontmatter> {
    match serde_yaml::from_str(raw) {
        Ok(frontmatter) => Ok(frontmatter),
        Err(_) => {
            let repaired = repair_frontmatter(raw);
            serde_yaml::from_str(&repaired).map_err(|error| anyhow!(error))
        }
    }
}

fn repair_frontmatter(raw: &str) -> String {
    let mut repaired = Vec::new();
    for line in raw.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            repaired.push(line.to_string());
            continue;
        }

        let Some((key, value)) = line.split_once(':') else {
            repaired.push(line.to_string());
            continue;
        };
        let key_trimmed = key.trim();
        let value_trimmed = value.trim();
        if !matches!(
            key_trimmed,
            "name" | "description" | "compatibility" | "license"
        ) {
            repaired.push(line.to_string());
            continue;
        }
        if value_trimmed.is_empty()
            || value_trimmed.starts_with('"')
            || value_trimmed.starts_with('\'')
            || value_trimmed.starts_with('[')
            || value_trimmed.starts_with('{')
            || value_trimmed.starts_with('|')
            || value_trimmed.starts_with('>')
        {
            repaired.push(line.to_string());
            continue;
        }

        repaired.push(format!(
            "{}: {}",
            key_trimmed,
            serde_json::to_string(value_trimmed).unwrap_or_else(|_| "\"\"".to_string())
        ));
    }
    repaired.join("\n")
}

fn split_frontmatter(raw: &str) -> Option<(&str, &str)> {
    let rest = raw.strip_prefix("---\n")?;
    let end = rest.find("\n---\n")?;
    let frontmatter = &rest[..end];
    let body = &rest[end + 5..];
    Some((frontmatter, body))
}