llman 0.0.30

A tool for managing LLM application rules(prompts) ...
Documentation
use crate::fs_utils::atomic_write_with_mode;
use crate::sdd::project::config::load_required_config;
use crate::sdd::shared::constants::LLMANSPEC_DIR_NAME;
use crate::sdd::shared::ids::validate_sdd_id;
use crate::sdd::spec::backend::{BACKEND, SpecBackend};
use crate::sdd::spec::fence::render_code_fence;
use crate::sdd::spec::frontmatter::{compose_with_frontmatter, split_frontmatter};
use crate::sdd::spec::ir::{MainSpecDoc, RequirementEntry, ScenarioEntry};
use anyhow::{Result, anyhow};
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone)]
pub struct SpecSkeletonArgs {
    pub capability: String,
    pub force: bool,
}

#[derive(Debug, Clone)]
pub struct SpecAddRequirementArgs {
    pub capability: String,
    pub req_id: String,
    pub title: String,
    pub statement: String,
}

#[derive(Debug, Clone)]
pub struct SpecAddScenarioArgs {
    pub capability: String,
    pub req_id: String,
    pub scenario_id: String,
    pub given: String,
    pub when_: String,
    pub then_: String,
}

pub fn run_skeleton(root: &Path, args: SpecSkeletonArgs) -> Result<()> {
    validate_sdd_id(&args.capability, "spec")?;
    let llmanspec_dir = root.join(LLMANSPEC_DIR_NAME);
    let _config = load_required_config(&llmanspec_dir)?;

    let spec_path = spec_path(root, &args.capability);
    if spec_path.exists() && !args.force {
        return Err(anyhow!(
            "spec skeleton target already exists: {} (pass --force to overwrite)",
            spec_path.display()
        ));
    }
    if let Some(parent) = spec_path.parent() {
        fs::create_dir_all(parent)?;
    }

    let frontmatter = default_spec_frontmatter_yaml();
    let spec = MainSpecDoc {
        kind: "llman.sdd.spec".to_string(),
        name: args.capability.clone(),
        purpose: "TODO: Describe this capability and its purpose.".to_string(),
        requirements: Vec::new(),
        scenarios: Vec::new(),
    };
    let payload = BACKEND.dump_main_spec(&spec)?;
    let body = render_code_fence("toon", &payload);
    let rebuilt = compose_with_frontmatter(Some(&frontmatter), &body);

    atomic_write_with_mode(&spec_path, rebuilt.as_bytes(), None)?;
    println!("{}", spec_path.display());
    Ok(())
}

pub fn run_add_requirement(root: &Path, args: SpecAddRequirementArgs) -> Result<()> {
    validate_sdd_id(&args.capability, "spec")?;
    validate_sdd_id(&args.req_id, "requirement")?;
    let llmanspec_dir = root.join(LLMANSPEC_DIR_NAME);
    let _config = load_required_config(&llmanspec_dir)?;
    if args.title.trim().is_empty() {
        return Err(anyhow!("title must not be empty"));
    }
    if args.statement.trim().is_empty() {
        return Err(anyhow!("statement must not be empty"));
    }
    if !contains_shall_or_must(args.statement.trim()) {
        return Err(anyhow!("statement must contain MUST or SHALL"));
    }

    let spec_path = spec_path(root, &args.capability);
    let content = fs::read_to_string(&spec_path)
        .map_err(|err| anyhow!("failed to read spec: {} ({})", spec_path.display(), err))?;
    let (frontmatter_yaml, body) = split_frontmatter(&content);
    let Some(frontmatter_yaml) = frontmatter_yaml else {
        return Err(anyhow!(
            "spec is missing YAML frontmatter: {} (run `llman sdd spec skeleton {}` to initialize)",
            spec_path.display(),
            args.capability
        ));
    };

    let context = format!("spec `{}`", args.capability);
    let old_doc = BACKEND.parse_main_spec(&body, &context)?;
    let mut spec = old_doc.clone();

    spec.kind = "llman.sdd.spec".to_string();
    spec.name = args.capability.clone();

    if spec
        .requirements
        .iter()
        .any(|row| row.req_id.trim() == args.req_id.trim())
    {
        return Err(anyhow!(
            "{context}: requirement already exists: `{}`",
            args.req_id
        ));
    }

    spec.requirements.push(RequirementEntry {
        req_id: args.req_id.trim().to_string(),
        title: args.title.trim().to_string(),
        statement: args.statement.trim().to_string(),
    });

    spec.scenarios.push(ScenarioEntry {
        req_id: args.req_id.trim().to_string(),
        id: "baseline".to_string(),
        given: "".to_string(),
        when_: "TODO: describe the trigger".to_string(),
        then_: "TODO: describe the expected result".to_string(),
    });

    let payload = BACKEND.dump_main_spec(&spec)?;
    let body = render_code_fence("toon", &payload);
    let rebuilt = compose_with_frontmatter(Some(&frontmatter_yaml), &body);
    atomic_write_with_mode(&spec_path, rebuilt.as_bytes(), None)?;
    println!("{}", spec_path.display());
    Ok(())
}

pub fn run_add_scenario(root: &Path, args: SpecAddScenarioArgs) -> Result<()> {
    validate_sdd_id(&args.capability, "spec")?;
    validate_sdd_id(&args.req_id, "requirement")?;
    validate_sdd_id(&args.scenario_id, "scenario")?;
    let llmanspec_dir = root.join(LLMANSPEC_DIR_NAME);
    let _config = load_required_config(&llmanspec_dir)?;
    if args.when_.trim().is_empty() {
        return Err(anyhow!("--when must not be empty"));
    }
    if args.then_.trim().is_empty() {
        return Err(anyhow!("--then must not be empty"));
    }

    let spec_path = spec_path(root, &args.capability);
    let content = fs::read_to_string(&spec_path)
        .map_err(|err| anyhow!("failed to read spec: {} ({})", spec_path.display(), err))?;
    let (frontmatter_yaml, body) = split_frontmatter(&content);
    let Some(frontmatter_yaml) = frontmatter_yaml else {
        return Err(anyhow!(
            "spec is missing YAML frontmatter: {} (run `llman sdd spec skeleton {}` to initialize)",
            spec_path.display(),
            args.capability
        ));
    };

    let context = format!("spec `{}`", args.capability);
    let _old_doc = BACKEND.parse_main_spec(&body, &context)?;
    let mut spec = _old_doc.clone();

    spec.kind = "llman.sdd.spec".to_string();
    spec.name = args.capability.clone();

    if !spec
        .requirements
        .iter()
        .any(|row| row.req_id.trim() == args.req_id.trim())
    {
        return Err(anyhow!(
            "{context}: unknown requirement `req_id` `{}`",
            args.req_id
        ));
    }

    if spec.scenarios.iter().any(|row| {
        row.req_id.trim() == args.req_id.trim() && row.id.trim() == args.scenario_id.trim()
    }) {
        return Err(anyhow!(
            "{context}: scenario already exists: (req_id, id) = (`{}`, `{}`)",
            args.req_id,
            args.scenario_id
        ));
    }

    spec.scenarios.push(ScenarioEntry {
        req_id: args.req_id.trim().to_string(),
        id: args.scenario_id.trim().to_string(),
        given: args.given.trim().to_string(),
        when_: args.when_.trim().to_string(),
        then_: args.then_.trim().to_string(),
    });

    let payload = BACKEND.dump_main_spec(&spec)?;
    let body = render_code_fence("toon", &payload);
    let rebuilt = compose_with_frontmatter(Some(&frontmatter_yaml), &body);
    atomic_write_with_mode(&spec_path, rebuilt.as_bytes(), None)?;
    println!("{}", spec_path.display());
    Ok(())
}

fn spec_path(root: &Path, capability: &str) -> PathBuf {
    root.join(LLMANSPEC_DIR_NAME)
        .join("specs")
        .join(capability)
        .join("spec.md")
}

fn default_spec_frontmatter_yaml() -> String {
    [
        "llman_spec_valid_scope:",
        "  - src/",
        "  - tests/",
        "llman_spec_valid_commands:",
        "  - cargo test",
        "llman_spec_evidence:",
        "  - \"TODO: add evidence (CI link, benchmark output, etc.)\"",
    ]
    .join("\n")
}

fn contains_shall_or_must(text: &str) -> bool {
    text.contains("SHALL") || text.contains("MUST")
}