llman 0.0.65

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, SPEC_FILE};
use crate::sdd::shared::ids::validate_sdd_id;
use crate::sdd::spec::backend::{BACKEND, SpecBackend};
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_dir = root
        .join(LLMANSPEC_DIR_NAME)
        .join("specs")
        .join(&args.capability);
    if spec_dir.exists() && !args.force {
        return Err(anyhow!(
            "spec skeleton target `{}` already exists (pass --force to overwrite)",
            spec_dir.display()
        ));
    }
    fs::create_dir_all(&spec_dir)?;

    // Allocate first req_id; printed as a hint for the next `add-requirement`.
    let first_req_id =
        crate::sdd::spec::req_registry::next_req_id(root).unwrap_or_else(|_| "r1".to_string());

    let spec = MainSpecDoc {
        kind: "llman.sdd.spec".to_string(),
        name: args.capability.clone(),
        purpose: "TODO: Describe this capability and its purpose.".to_string(),
        valid_scope: vec!["src/".to_string(), "tests/".to_string()],
        requirements: Vec::new(),
        scenarios: Vec::new(),
    };
    let spec_path = spec_dir.join(SPEC_FILE);
    let payload = BACKEND.dump_main_spec(&spec)?;
    atomic_write_with_mode(&spec_path, payload.as_bytes(), None)?;
    println!("wrote {}", spec_path.display());
    println!(
        "next-req-id: {first_req_id} (use `llman sdd spec add-requirement {name} {first_req_id} --title ... --statement ...`)",
        name = args.capability,
    );

    // BDD-on: also scaffold a .feature harness file.
    if config.bdd.is_some() {
        let lang = crate::sdd::spec::validation::locale_to_gherkin_lang(
            Some(&config.locale),
            config.bdd.as_ref(),
        );
        let feature_path = spec_dir.join(format!("{}.feature", args.capability));
        let feature_contents = format!(
            "# language: {lang}\n\n@req:{req_id}\nFeature: {name}\n  # Replace with real GWT scenarios after `add-requirement`.\n  # See: `llman sdd validate {name} --strict` for format guidance.\n",
            lang = lang,
            req_id = first_req_id,
            name = args.capability,
        );
        fs::write(&feature_path, feature_contents)?;
        println!("wrote {}", feature_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"));
    }

    crate::sdd::spec::req_registry::ensure_req_id_globally_free(root, &args.req_id)?;

    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 context = format!("spec `{}`", args.capability);
    let mut spec = BACKEND.parse_main_spec(&content, &context)?;
    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(),
        feature: true,
    });

    let payload = BACKEND.dump_main_spec(&spec)?;
    atomic_write_with_mode(&spec_path, payload.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 context = format!("spec `{}`", args.capability);
    let mut spec = BACKEND.parse_main_spec(&content, &context)?;
    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(),
        feature: true,
    });

    let payload = BACKEND.dump_main_spec(&spec)?;
    atomic_write_with_mode(&spec_path, payload.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_FILE)
}

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