llman 0.0.29

A tool for managing LLM application rules(prompts) ...
Documentation
use crate::sdd::project::config::load_required_config;
use crate::sdd::shared::constants::LLMANSPEC_DIR_NAME;
use crate::sdd::shared::discovery::{list_changes, list_specs};
use crate::sdd::shared::ids::validate_sdd_id;
use crate::sdd::shared::interactive::is_interactive;
use crate::sdd::shared::match_utils::nearest_matches;
use crate::sdd::spec::parser::{Requirement, parse_change, parse_spec};
use anyhow::{Result, anyhow};
use inquire::Select;
use std::fmt;
use std::fs;
use std::path::Path;

#[derive(Debug, Clone)]
pub struct ShowArgs {
    pub item: Option<String>,
    pub json: bool,
    pub compact_json: bool,
    pub item_type: Option<String>,
    pub no_interactive: bool,
    pub deltas_only: bool,
    pub requirements_only: bool,
    pub requirements: bool,
    pub no_scenarios: bool,
    pub requirement: Option<usize>,
    pub meta_only: bool,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ItemType {
    Change,
    Spec,
}

impl ItemType {
    fn as_str(self) -> &'static str {
        match self {
            ItemType::Change => "change",
            ItemType::Spec => "spec",
        }
    }
}

impl fmt::Display for ItemType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let label = match self {
            ItemType::Change => t!("sdd.show.option_change"),
            ItemType::Spec => t!("sdd.show.option_spec"),
        };
        write!(f, "{label}")
    }
}

pub fn run(args: ShowArgs) -> Result<()> {
    let root = Path::new(".");
    let interactive = is_interactive(args.no_interactive);
    let type_override = normalize_type(args.item_type.as_deref());

    if args.item.is_none() {
        if interactive {
            let choice = Select::new(
                &t!("sdd.show.select_type"),
                vec![ItemType::Change, ItemType::Spec],
            )
            .prompt()?;
            return run_interactive_by_type(root, choice, &args);
        }
        return Err(anyhow!(non_interactive_hint_message()));
    }

    let Some(item) = args.item.as_deref() else {
        return Err(anyhow!(non_interactive_hint_message()));
    };
    show_direct(root, item, type_override, &args)
}

fn normalize_type(value: Option<&str>) -> Option<ItemType> {
    let value = value?.to_lowercase();
    match value.as_str() {
        "change" => Some(ItemType::Change),
        "spec" => Some(ItemType::Spec),
        _ => None,
    }
}

fn run_interactive_by_type(root: &Path, item_type: ItemType, args: &ShowArgs) -> Result<()> {
    match item_type {
        ItemType::Change => {
            let changes = list_changes(root)?;
            if changes.is_empty() {
                return Err(anyhow!(t!("sdd.show.no_changes_found")));
            }
            let picked = Select::new(&t!("sdd.show.pick_change"), changes).prompt()?;
            show_change(root, &picked, args)
        }
        ItemType::Spec => {
            let specs = list_specs(root)?;
            if specs.is_empty() {
                return Err(anyhow!(t!("sdd.show.no_specs_found")));
            }
            let picked = Select::new(&t!("sdd.show.pick_spec"), specs).prompt()?;
            show_spec(root, &picked, args)
        }
    }
}

fn show_direct(
    root: &Path,
    item: &str,
    type_override: Option<ItemType>,
    args: &ShowArgs,
) -> Result<()> {
    let mut changes: Vec<String> = Vec::new();
    let mut specs: Vec<String> = Vec::new();
    let mut is_change = false;
    let mut is_spec = false;

    match type_override {
        Some(ItemType::Change) => {
            changes = list_changes(root)?;
            is_change = changes.contains(&item.to_string());
        }
        Some(ItemType::Spec) => {
            specs = list_specs(root)?;
            is_spec = specs.contains(&item.to_string());
        }
        None => {
            changes = list_changes(root)?;
            specs = list_specs(root)?;
            is_change = changes.contains(&item.to_string());
            is_spec = specs.contains(&item.to_string());
        }
    }

    let resolved_type = type_override.or(if is_change {
        Some(ItemType::Change)
    } else if is_spec {
        Some(ItemType::Spec)
    } else {
        None
    });

    let Some(resolved_type) = resolved_type else {
        let mut candidates = Vec::new();
        if changes.is_empty() && specs.is_empty() {
            candidates.extend(list_changes(root)?);
            candidates.extend(list_specs(root)?);
        } else {
            candidates.extend(changes);
            candidates.extend(specs);
        }
        let suggestions = nearest_matches(item, &candidates, 5);

        let mut msg = t!("sdd.show.unknown_item", item = item).to_string();
        if !suggestions.is_empty() {
            msg.push('\n');
            msg.push_str(&t!("sdd.show.did_you_mean", items = suggestions.join(", ")));
        }
        return Err(anyhow!(msg));
    };

    if type_override.is_none() && is_change && is_spec {
        return Err(anyhow!(
            "{}\n{}",
            t!("sdd.show.ambiguous_item", item = item),
            t!("sdd.show.ambiguous_hint")
        ));
    }
    warn_irrelevant_flags(resolved_type, args);

    match resolved_type {
        ItemType::Change => show_change(root, item, args),
        ItemType::Spec => show_spec(root, item, args),
    }
}

fn show_change(root: &Path, change_id: &str, args: &ShowArgs) -> Result<()> {
    validate_sdd_id(change_id, "change")?;
    let change_dir = root
        .join(LLMANSPEC_DIR_NAME)
        .join("changes")
        .join(change_id);
    let proposal_path = change_dir.join("proposal.md");
    if !proposal_path.exists() {
        return Err(anyhow!(t!("sdd.show.change_not_found", id = change_id)));
    }

    if args.json {
        let llmanspec_dir = root.join(LLMANSPEC_DIR_NAME);
        let _config = load_required_config(&llmanspec_dir)?;

        let content = fs::read_to_string(&proposal_path)?;
        let change = parse_change(&content, change_id, &change_dir)?;
        let title = extract_title(&content, change_id);
        let deltas = change.deltas;
        if args.requirements_only {
            eprintln!("{}", t!("sdd.show.requirements_only_deprecated"));
        }
        let output = serde_json::json!({
            "id": change_id,
            "title": title,
            "deltaCount": deltas.len(),
            "deltas": deltas
        });
        print_json(&output, args.compact_json)?;
        return Ok(());
    }

    let content = fs::read_to_string(&proposal_path)?;
    print!("{content}");
    Ok(())
}

fn show_spec(root: &Path, spec_id: &str, args: &ShowArgs) -> Result<()> {
    validate_sdd_id(spec_id, "spec")?;
    let llmanspec_dir = root.join(LLMANSPEC_DIR_NAME);
    let _config = load_required_config(&llmanspec_dir)?;

    let spec_path = root
        .join(LLMANSPEC_DIR_NAME)
        .join("specs")
        .join(spec_id)
        .join("spec.md");
    if !spec_path.exists() {
        return Err(anyhow!(t!("sdd.show.spec_not_found", id = spec_id)));
    }

    if args.json {
        if args.requirements && args.requirement.is_some() {
            return Err(anyhow!(t!("sdd.show.requirements_conflict")));
        }
        let content = fs::read_to_string(&spec_path)?;
        let spec = parse_spec(&content, spec_id)?;
        if args.meta_only {
            let output = serde_json::json!({
                "id": spec_id,
                "featureId": spec.name,
                "title": spec.name,
                "overview": spec.overview,
                "requirementCount": spec.requirements.len(),
                "metadata": spec.metadata
            });
            print_json(&output, args.compact_json)?;
            return Ok(());
        }

        let requirements = filter_requirements(&spec.requirements, args)?;
        let output = serde_json::json!({
            "id": spec_id,
            "title": spec.name,
            "overview": spec.overview,
            "requirementCount": requirements.len(),
            "requirements": requirements,
            "metadata": spec.metadata
        });
        print_json(&output, args.compact_json)?;
        return Ok(());
    }

    let content = fs::read_to_string(&spec_path)?;
    print!("{content}");
    Ok(())
}

fn filter_requirements(requirements: &[Requirement], args: &ShowArgs) -> Result<Vec<Requirement>> {
    let requirement_index = match args.requirement {
        Some(index) => {
            if index == 0 || index > requirements.len() {
                return Err(anyhow!(t!(
                    "sdd.show.requirement_not_found",
                    id = index,
                    count = requirements.len()
                )));
            }
            Some(index - 1)
        }
        None => None,
    };

    let include_scenarios = !args.requirements && !args.no_scenarios;
    let selected: Vec<Requirement> = if let Some(index) = requirement_index {
        vec![requirements[index].clone()]
    } else {
        requirements.to_vec()
    };

    Ok(selected
        .into_iter()
        .map(|req| Requirement {
            text: req.text,
            scenarios: if include_scenarios {
                req.scenarios
            } else {
                Vec::new()
            },
        })
        .collect())
}

fn warn_irrelevant_flags(item_type: ItemType, args: &ShowArgs) {
    let mut ignored = Vec::new();
    match item_type {
        ItemType::Change => {
            if args.requirements {
                ignored.push("--requirements");
            }
            if args.no_scenarios {
                ignored.push("--no-scenarios");
            }
            if args.requirement.is_some() {
                ignored.push("--requirement");
            }
            if args.meta_only {
                ignored.push("--meta-only");
            }
        }
        ItemType::Spec => {
            if args.deltas_only {
                ignored.push("--deltas-only");
            }
            if args.requirements_only {
                ignored.push("--requirements-only");
            }
        }
    }

    if !ignored.is_empty() {
        eprintln!(
            "{}",
            t!(
                "sdd.show.ignore_flags",
                item_type = item_type.as_str(),
                flags = ignored.join(", ")
            )
        );
    }
}

fn print_json(value: &serde_json::Value, compact: bool) -> Result<()> {
    if compact {
        println!("{}", serde_json::to_string(value)?);
    } else {
        println!("{}", serde_json::to_string_pretty(value)?);
    }
    Ok(())
}

fn extract_title(content: &str, fallback: &str) -> String {
    for line in content.lines() {
        let trimmed = line.trim_start();
        if let Some(title) = trimmed.strip_prefix("# ") {
            let cleaned = title.trim();
            if let Some(stripped) = cleaned.strip_prefix("Change: ") {
                return stripped.trim().to_string();
            }
            return cleaned.to_string();
        }
    }
    fallback.to_string()
}

fn non_interactive_hint_message() -> String {
    [
        t!("sdd.show.non_interactive.line1"),
        t!("sdd.show.non_interactive.line2"),
        t!("sdd.show.non_interactive.line3"),
        t!("sdd.show.non_interactive.line4"),
        t!("sdd.show.non_interactive.line5"),
    ]
    .join("\n")
}