llman 0.0.55

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::{DeltaOpEntry, DeltaSpecDoc, ScenarioEntry};
use anyhow::{Result, anyhow};
use std::fs;
use std::path::{Path, PathBuf};

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

#[derive(Debug, Clone)]
pub struct DeltaAddOpArgs {
    pub change_id: String,
    pub capability: String,
    pub op: String,
    pub req_id: String,
    pub title: Option<String>,
    pub statement: Option<String>,
    pub from: Option<String>,
    pub to: Option<String>,
    pub name: Option<String>,
}

#[derive(Debug, Clone)]
pub struct DeltaAddScenarioArgs {
    pub change_id: String,
    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: DeltaSkeletonArgs) -> Result<()> {
    validate_sdd_id(&args.change_id, "change")?;
    validate_sdd_id(&args.capability, "spec")?;
    let llmanspec_dir = root.join(LLMANSPEC_DIR_NAME);
    let _config = load_required_config(&llmanspec_dir)?;

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

    let delta = DeltaSpecDoc {
        kind: "llman.sdd.delta".to_string(),
        ops: Vec::new(),
        op_scenarios: Vec::new(),
    };
    let payload = BACKEND.dump_delta_spec(&delta)?;
    atomic_write_with_mode(&delta_path, payload.as_bytes(), None)?;
    println!("{}", delta_path.display());
    Ok(())
}

pub fn run_add_op(root: &Path, args: DeltaAddOpArgs) -> Result<()> {
    validate_sdd_id(&args.change_id, "change")?;
    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)?;

    let delta_path = delta_path(root, &args.change_id, &args.capability);
    let content = fs::read_to_string(&delta_path).map_err(|err| {
        anyhow!(
            "failed to read delta spec: {} ({})",
            delta_path.display(),
            err
        )
    })?;

    let context = format!(
        "delta spec `{}` for change `{}`",
        args.capability, args.change_id
    );
    let old_doc = BACKEND.parse_delta_spec(&content, &context)?;
    let mut delta = old_doc.clone();
    delta.kind = "llman.sdd.delta".to_string();

    let req_id = args.req_id.trim().to_string();
    if delta.ops.iter().any(|row| row.req_id.trim() == req_id) {
        return Err(anyhow!(
            "{context}: op already exists for `req_id` `{}`",
            req_id
        ));
    }

    let op = args.op.trim().to_ascii_lowercase();
    let row = build_op_row(&context, &op, &req_id, &args)?;
    delta.ops.push(row);

    let payload = BACKEND.dump_delta_spec(&delta)?;
    atomic_write_with_mode(&delta_path, payload.as_bytes(), None)?;
    println!("{}", delta_path.display());
    Ok(())
}

pub fn run_add_scenario(root: &Path, args: DeltaAddScenarioArgs) -> Result<()> {
    validate_sdd_id(&args.change_id, "change")?;
    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 delta_path = delta_path(root, &args.change_id, &args.capability);
    let content = fs::read_to_string(&delta_path).map_err(|err| {
        anyhow!(
            "failed to read delta spec: {} ({})",
            delta_path.display(),
            err
        )
    })?;

    let context = format!(
        "delta spec `{}` for change `{}`",
        args.capability, args.change_id
    );
    let old_doc = BACKEND.parse_delta_spec(&content, &context)?;
    let mut delta = old_doc.clone();
    delta.kind = "llman.sdd.delta".to_string();

    let req_id = args.req_id.trim();
    let op = delta
        .ops
        .iter()
        .find(|row| row.req_id.trim() == req_id)
        .ok_or_else(|| anyhow!("{context}: unknown op `req_id` `{}`", args.req_id))?
        .op
        .trim()
        .to_ascii_lowercase();
    if op != "add_requirement" && op != "modify_requirement" {
        return Err(anyhow!(
            "{context}: op scenarios are only allowed for add/modify ops; found `{}` for `req_id` `{}`",
            op,
            args.req_id
        ));
    }

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

    delta.op_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_delta_spec(&delta)?;
    atomic_write_with_mode(&delta_path, payload.as_bytes(), None)?;
    println!("{}", delta_path.display());
    Ok(())
}

fn delta_path(root: &Path, change_id: &str, capability: &str) -> PathBuf {
    root.join(LLMANSPEC_DIR_NAME)
        .join("changes")
        .join(change_id)
        .join("specs")
        .join(capability)
        .join(SPEC_FILE)
}

fn build_op_row(
    context: &str,
    op: &str,
    req_id: &str,
    args: &DeltaAddOpArgs,
) -> Result<DeltaOpEntry> {
    match op {
        "add_requirement" | "modify_requirement" => {
            let title = args
                .title
                .as_deref()
                .map(|v| v.trim())
                .filter(|v| !v.is_empty())
                .ok_or_else(|| anyhow!("{context}: `--title` is required for op `{}`", op))?;
            let statement = args
                .statement
                .as_deref()
                .map(|v| v.trim())
                .filter(|v| !v.is_empty())
                .ok_or_else(|| anyhow!("{context}: `--statement` is required for op `{}`", op))?;
            if !contains_shall_or_must(statement) {
                return Err(anyhow!(
                    "{context}: `--statement` must contain MUST or SHALL"
                ));
            }
            Ok(DeltaOpEntry {
                op: op.to_string(),
                req_id: req_id.to_string(),
                title: Some(title.to_string()),
                statement: Some(statement.to_string()),
                from: None,
                to: None,
                name: None,
            })
        }
        "remove_requirement" => {
            let name = args
                .name
                .as_deref()
                .map(|v| v.trim())
                .filter(|v| !v.is_empty())
                .map(|v| v.to_string());
            Ok(DeltaOpEntry {
                op: op.to_string(),
                req_id: req_id.to_string(),
                title: None,
                statement: None,
                from: None,
                to: None,
                name,
            })
        }
        "rename_requirement" => {
            let from = args
                .from
                .as_deref()
                .map(|v| v.trim())
                .filter(|v| !v.is_empty())
                .ok_or_else(|| anyhow!("{context}: `--from` is required for op `{}`", op))?;
            let to = args
                .to
                .as_deref()
                .map(|v| v.trim())
                .filter(|v| !v.is_empty())
                .ok_or_else(|| anyhow!("{context}: `--to` is required for op `{}`", op))?;
            Ok(DeltaOpEntry {
                op: op.to_string(),
                req_id: req_id.to_string(),
                title: None,
                statement: None,
                from: Some(from.to_string()),
                to: Some(to.to_string()),
                name: None,
            })
        }
        _ => Err(anyhow!(
            "{context}: unsupported op `{}` (expected add_requirement/modify_requirement/remove_requirement/rename_requirement)",
            op
        )),
    }
}

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