cumulus-sdd 0.1.0

Token-efficient spec-driven development CLI (embedded prompts + slash-command generators).
//! Transform embedded skill definitions into target-specific slash commands.
//!
//! Targets:
//!   copilot -> .github/prompts/<name>.prompt.md   (native `/name` commands)
//!   claude  -> .claude/skills/<name>/SKILL.md
//!   gemini  -> .gemini/commands/<name>.toml
//!
//! Argument placeholder token in sources: `{{ARG}}` — rewritten per target.

use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use crate::embed::{Skill, SKILLS};

pub const TARGETS: [&str; 3] = ["copilot", "claude", "gemini"];

/// Parsed skill: relevant frontmatter scalars + prompt body.
struct Parsed {
    name: String,
    description: String,
    argument_name: String,
    body: String,
}

/// Split `---\n...\n---\n<body>` into frontmatter map + body.
fn parse(skill: &Skill) -> Result<Parsed, String> {
    let raw = skill.raw;
    let rest = raw
        .strip_prefix("---\n")
        .ok_or_else(|| format!("{}: missing frontmatter", skill.name))?;
    let end = rest
        .find("\n---")
        .ok_or_else(|| format!("{}: unterminated frontmatter", skill.name))?;
    let fm = &rest[..end];
    // Body starts after the closing `---` line.
    let after = &rest[end + 4..]; // skip "\n---"
    let body = after.trim_start_matches(['\n', '\r']).trim_end().to_string();

    let mut description = String::new();
    let mut argument_name = "story-id".to_string();
    for line in fm.lines() {
        let t = line.trim_start();
        // Ignore list items / nested keys; we only need top-level scalars.
        if t.starts_with('-') {
            continue;
        }
        if let Some((k, v)) = line.split_once(':') {
            let key = k.trim();
            let val = strip_quotes(v.trim());
            match key {
                "description" => description = val.to_string(),
                "argument_name" if !val.is_empty() => argument_name = val.to_string(),
                _ => {}
            }
        }
    }

    Ok(Parsed {
        name: skill.name.to_string(),
        description,
        argument_name,
        body,
    })
}

fn strip_quotes(s: &str) -> &str {
    let b = s.as_bytes();
    if b.len() >= 2
        && ((b[0] == b'"' && b[b.len() - 1] == b'"') || (b[0] == b'\'' && b[b.len() - 1] == b'\''))
    {
        &s[1..s.len() - 1]
    } else {
        s
    }
}

fn camel(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut up = false;
    for c in s.chars() {
        if c == '-' || c == '_' {
            up = true;
        } else if up {
            out.extend(c.to_uppercase());
            up = false;
        } else {
            out.push(c);
        }
    }
    out
}

/// Rewrite the `{{ARG}}` token for a given target.
fn rewrite_arg(body: &str, target: &str, p: &Parsed) -> String {
    let repl = match target {
        "copilot" => format!("${{input:{}:{}}}", camel(&p.argument_name), p.argument_name),
        "claude" => "$ARGUMENTS".to_string(),
        "gemini" => "{{args}}".to_string(),
        _ => "{{ARG}}".to_string(),
    };
    body.replace("{{ARG}}", &repl)
}

fn toml_escape(s: &str) -> String {
    s.replace('\\', "\\\\").replace("\"\"\"", "\\\"\\\"\\\"")
}

fn write(path: &Path, contents: &str) -> io::Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(path, contents)
}

/// Emit all skills for one target into `out_root`. Returns written paths.
fn emit_target(target: &str, out_root: &Path) -> io::Result<Vec<PathBuf>> {
    let mut written = Vec::new();
    for skill in SKILLS {
        let p = parse(skill).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
        let prompt = rewrite_arg(&p.body, target, &p);
        let (path, contents) = match target {
            "copilot" => {
                let path = out_root
                    .join(".github/prompts")
                    .join(format!("{}.prompt.md", p.name));
                let c = format!(
                    "---\nmode: agent\ndescription: {}\n---\n{}\n",
                    p.description, prompt
                );
                (path, c)
            }
            "claude" => {
                let path = out_root
                    .join(".claude/skills")
                    .join(&p.name)
                    .join("SKILL.md");
                let c = format!(
                    "---\nname: {}\ndescription: {}\n---\n{}\n",
                    p.name, p.description, prompt
                );
                (path, c)
            }
            "gemini" => {
                let path = out_root
                    .join(".gemini/commands")
                    .join(format!("{}.toml", p.name));
                let c = format!(
                    "description = \"{}\"\nprompt = \"\"\"\n{}\n\"\"\"\n",
                    p.description.replace('"', "\\\""),
                    toml_escape(&prompt)
                );
                (path, c)
            }
            other => {
                return Err(io::Error::new(
                    io::ErrorKind::InvalidInput,
                    format!("unknown target '{other}'"),
                ))
            }
        };
        write(&path, &contents)?;
        written.push(path);
    }
    Ok(written)
}

/// Emit one target or, when `target == "all"`, every target.
pub fn emit(target: &str, out_root: &Path) -> io::Result<Vec<(String, Vec<PathBuf>)>> {
    let targets: Vec<&str> = if target == "all" {
        TARGETS.to_vec()
    } else if TARGETS.contains(&target) {
        vec![target]
    } else {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!(
                "invalid target '{target}' — use one of: {}, all",
                TARGETS.join(", ")
            ),
        ));
    };

    let mut result = Vec::new();
    for t in targets {
        let files = emit_target(t, out_root)?;
        result.push((t.to_string(), files));
    }
    Ok(result)
}